# API The HTTP surface of nuntius. Every form defined in `config.toml` exposes the same verbs on its configured `path`; the server adds two global endpoints. There is no authentication on the form endpoints: the browser contract is the per-form CORS allowlist, and `GET /metrics` optionally carries a bearer token. ## HTTP API | Method | Path | Purpose | |---|---|---| | `POST` | `
` | validate, honeypot-check, deliver (mail plus the optional Telegram notification); newsletter forms start the double opt-in | | `OPTIONS` | `` | CORS preflight | | `GET` | `/confirm` | redeem a newsletter double opt-in token | | `GET` | `/health` | listener liveness and the form count | | `GET` | `/metrics` | lifetime counters per form and in total | Any other method on a form path answers `405 Method Not Allowed`. ### `POST ` Two body shapes carry the same fields. The JSON contract is one JSON object per request. The plain HTML form contract accepts `application/x-www-form-urlencoded` and `multipart/form-data` posts under the fixed field names `name`, `email`, `service` and `message` plus the configured honeypot field, so an ordinary `` works without JavaScript; file parts are ignored. | Field | Required | Notes | |---|---|---| | `name` | `contact`, `feedback`, `generic` presets; tunable with `require_name` | 2 to 100 runes by default | | `email` | always | RFC 5322, never length-limited | | `service` | optional | checked against the form's allow-list | | `message` | `contact`, `feedback`, `generic` presets; tunable with `require_message` | 10 to 5,000 runes by default | ```sh curl -X POST http://localhost:8080/api/nuntius/contact \ -H "Content-Type: application/json" \ -H "Origin: https://example.com" \ -d '{"name":"Jane Doe","email":"jane@example.com","service":"architecture","message":"Hello, I would like to discuss an engagement."}' # → 200 {"ok": true} ``` ```sh curl -i -X POST http://localhost:8080/api/nuntius/contact \ -d 'name=Jane Doe&email=jane@example.com&message=Hello, I would like to discuss an engagement.' # → 303 See Other, Location: https://example.com/thanks (when redirect_url is set) ``` | Status | Body | When | |---|---|---| | `200` | `{"ok": true}` | Submission accepted and delivered. A form with `forms.telegram` counts as delivered when either the mail or the chat message gets through | | `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) | | `303` | _redirect_ | A form with `redirect_url` set answers every accepted submission, honeypot hits and duplicate signups included, with `303 See Other` to the configured page | | `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid | | `400` | `{"error": "invalid_body", "message": "..."}` | The body is unparsable: not valid JSON, or a broken form body | | `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` | | `413` | `{"error": "body_too_large", "message": "..."}` | Body exceeds `server.max_body_bytes` | | `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form | | `500` | `{"error": "send_failed", "message": "..."}` | Both delivery channels failed: the SMTP round-trip and, where configured, the Telegram notification | | `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter`, or any form with `archive` set: could not write the JSONL line | The error bodies are one shape: `error` is a machine-readable code (`validation`, `invalid_body`, `origin_not_allowed`, `rate_limited`, `send_failed`, `storage_failed`), `message` a human-readable summary, and `details[]` carries the per-field validation failures: ```json { "error": "validation", "details": [ { "field": "name", "message": "name must be at least 2 characters" }, { "field": "email", "message": "email is invalid" } ] } ``` Rate limiting is a per-IP token bucket per form: the bucket size equals `rate_limit_per_hour`, it refills at `perHour / 3600` tokens per second, and `rate_limit_per_hour: 0` disables it. State is per-process and survives graceful restarts via the snapshot file under `data_dir/`; scaled horizontally, each replica has its own bucket. The IP is the connection peer address unless `server.trust_proxy_headers` opts in to proxy headers. #### CORS A browser preflight is an `OPTIONS` request with `Origin` and `Access-Control-Request-Method`; an allowed origin gets `204` with `Access-Control-Allow-Origin` echoed, `POST, OPTIONS` methods, `Content-Type` allowed and `Vary: Origin`. A plain HTML form post carries an `Origin` header like any browser POST, so a same-origin `` lists its own origin in `allowed_origins` too. An absent `Origin` header is allowed, which makes `curl` work out of the box; a present origin that is not allowlisted gets `403`. Wildcard `*` is not supported. ### `GET /confirm` Newsletter forms expose this endpoint next to their POST route; the link inside every confirmation mail points here, and the response is a small HTML page. | Outcome | Status | Page | |---|---|---| | Valid, unexpired token | `200` | "Subscription confirmed"; the address moves into the subscriber log and the owner is notified | | Unknown, already redeemed or expired token | `410` | "Link expired", inviting a fresh signup | | Storage failure while saving the record | `500` | "Almost there", the link stays usable for a retry | Tokens are single-use 256-bit random values; only their SHA-256 hashes are stored server-side. #### Flow ```mermaid sequenceDiagram participant Subscriber participant Nuntius participant Mailbox as Subscriber's inbox Subscriber->>Nuntius: POST (email) Nuntius->>Nuntius: store pending (SHA-256 token) Nuntius-->>Subscriber: 200 ok Nuntius->>Mailbox: confirmation link Subscriber->>Nuntius: GET /confirm?token=... Nuntius->>Nuntius: append subscriber, notify owner Nuntius-->>Subscriber: 200 confirmed page ``` ### `GET /health` ```sh curl -s http://localhost:8080/health # → {"status":"ok","forms":3} ``` `status` is always `"ok"` while the process is up; there is no deep health check. `forms` is the number of forms loaded from `config.toml`. The endpoint is not rate-limited and is not CORS-checked. ### `GET /metrics` ```sh curl -s -H "Authorization: Bearer $NUNTIUS_METRICS_TOKEN" http://localhost:8080/metrics ``` ```json { "totals": { "received": 42, "honeypot_blocked": 7, "rate_limited": 3, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 2, "validation_failed": 5, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 1, "sent": 19, "auto_reply_failed": 0, "telegram_failed": 0 }, "forms": { "/api/nuntius/contact": { "received": 30, "honeypot_blocked": 5, "rate_limited": 2, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 1, "validation_failed": 3, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 0, "sent": 15, "auto_reply_failed": 0, "telegram_failed": 0 } } } ``` | Counter | Meaning | |---|---| | `received` | POST requests that passed the origin check | | `honeypot_blocked` | Bot submissions dropped by the honeypot field | | `rate_limited` | Requests rejected with `429 rate_limited` | | `origin_blocked` | Requests rejected with `403 origin_not_allowed` | | `body_too_large` | Requests rejected with `413 body_too_large` | | `invalid_body` | Requests rejected because the body was unreadable or unparsable, whichever shape it claimed | | `validation_failed` | Requests rejected with field-level `400 validation` errors | | `send_failed` | Deliveries where neither channel got through | | `persist_failed` | Newsletter submissions or archive writes whose disk append failed | | `duplicate_signup` | Newsletter signups skipped because the address is already recorded | | `confirmation_sent` | Newsletter signups whose confirmation link was mailed | | `confirmed` | Confirmation links successfully redeemed | | `confirmation_failed` | Confirmations that could not be saved, or clicked after expiry or unknown | | `sent` | Completed owner notifications: the submission mail for non-newsletter forms | | `auto_reply_failed` | Receipts to the submitter that failed after an accepted submission | | `telegram_failed` | Telegram notifications that failed; the submission stays delivered when the mail went out | Counters are lifetime values held in memory; they reset on restart, the same trade-off as the rate-limit buckets. The endpoint is not rate-limited and sends no CORS headers, so third-party pages cannot read submission volumes. With `server.metrics_token` set it requires that token as a bearer credential and answers `401` with a `WWW-Authenticate: Bearer` challenge otherwise; without the key it is open, protected at the reverse proxy like any operational surface. ## Notes - Request bodies are bounded by `server.max_body_bytes` (1 MiB by default) and read fully into memory; for a 5,000-rune message this is negligible. - Logs are one JSON line per request (`method`, `path`, `status`, `duration_ms`, `ip`) via `log/slog`, plus one line per sent or failed message and one per honeypot hit. No PII beyond the client IP, and no credentials anywhere.