8.3 KiB
HTTP API Reference
Every form defined in config.toml exposes the same three HTTP verbs on its configured path. The server also exposes one global endpoint (GET /health).
Per-Form Endpoints
For each form in the config:
| Method | Behaviour |
|---|---|
POST |
Validate → honeypot → email send → (newsletter only) persist to disk |
OPTIONS |
CORS preflight. Returns 204 No Content if the origin is allowed; 403 otherwise. |
| other | 405 Method Not Allowed (Go 1.22+ http.ServeMux semantics) |
Request shape
All four form types accept a single JSON object as the request body. Required fields depend on the form's type:
| Type | Required | Optional |
|---|---|---|
contact |
name, email, message |
service, honeypot |
feedback |
name, email, message |
honeypot |
newsletter |
email |
honeypot |
generic |
name, email, message |
honeypot |
The honeypot field name in the request body is configured per form (honeypot_field in config.toml, default "website"). The handler reads it into contactform.Request.Honeypot (which is json:"-", so it does not appear in the public Go API) and drops the request silently if it is non-empty.
Example: contact
POST /api/nuntius/contact HTTP/1.1
Host: example.com
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"service": "architecture",
"message": "Hello, I would like to discuss an engagement."
}
Example: feedback
POST /api/nuntius/feedback HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I love how the site uses Vue.js with zero runtime deps."
}
Example: newsletter
POST /api/nuntius/newsletter HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{ "email": "jane@example.com" }
Response Status Codes
| Status | Body | When |
|---|---|---|
200 |
{"ok": true} |
Submission accepted; mail sent (and subscriber persisted for newsletter) |
200 |
{"ok": true} |
Honeypot triggered (silent accept, no mail, no log) |
204 |
empty | CORS preflight succeeded |
400 |
{"error": "invalid_json", "message": "..."} |
Body is not valid JSON |
400 |
{"error": "validation", "details": [...]} |
One or more fields are invalid |
403 |
{"error": "origin_not_allowed"} |
Origin header is not in the form's allowed_origins |
405 |
empty | Method not allowed (e.g. GET on a form path) |
429 |
{"error": "rate_limited", "message": "..."} |
Token bucket empty for this IP / form |
500 |
{"error": "send_failed", "message": "..."} |
SMTP round-trip failed |
500 |
{"error": "storage_failed", "message": "..."} |
newsletter only — could not write the JSONL line |
Response Bodies
Success
{ "ok": true }
Validation error
{
"error": "validation",
"details": [
{ "field": "name", "message": "name must be at least 2 characters" },
{ "field": "email", "message": "email is invalid" },
{ "field": "message", "message": "message must be at least 10 characters" }
]
}
details[] always has at least one entry when error == "validation". Each entry has field and message strings. The message is human-readable and safe to surface in the UI.
JSON parse error
{ "error": "invalid_json", "message": "Could not parse JSON body." }
CORS rejection
{ "error": "origin_not_allowed" }
Rate limit
{ "error": "rate_limited", "message": "Too many requests, please try again later." }
SMTP failure
{ "error": "send_failed", "message": "Could not send email." }
Storage failure (newsletter only)
{ "error": "storage_failed", "message": "Could not record subscription." }
CORS
CORS is enforced per form. The flow:
-
Browser sends a preflight
OPTIONSrequest withOriginandAccess-Control-Request-Method. -
nuntius checks
Originagainst the form'sallowed_origins. -
If allowed, the response carries:
Access-Control-Allow-Origin: <echoed origin> Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Headers: Content-Type Vary: Origin -
If not allowed, the response is
403with{"error": "origin_not_allowed"}.
For the actual POST:
- An absent
Originheader is allowed (the form is treated as a non-browser client). This makescurlwork out of the box. - A present
Originthat is not inallowed_originsreturns403. - A present
Originthat is inallowed_originsgets the same CORS headers as the preflight.
Wildcard * is not supported. List every origin explicitly.
Rate Limiting
Each form has its own in-memory token bucket. The bucket size equals rate_limit_per_hour and refills at perHour / 3600 tokens per second. The first request from a new IP creates a full bucket.
To disable rate limiting on a form, set rate_limit_per_hour: 0 explicitly. There is no global safety net — use with care.
Rate limit state is per-process and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is replicas × per_form_limit.
The IP used for rate limiting is taken from the same precedence chain as the log line:
X-Forwarded-For(first hop, before any comma)X-Real-IPRemoteAddr(with the port stripped)
Caddy forwards X-Forwarded-For automatically — so the real client IP is always passed to nuntius without any extra configuration.
Health Check
GET /health
GET /health HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "status": "ok", "forms": 3 }
| Field | Type | Description |
|---|---|---|
status |
string | Always "ok" while the process is up. There is no deep health check; the endpoint is suitable for a load balancer that wants to know "is the listener alive". |
forms |
int | Number of forms loaded from config.toml at startup. |
This endpoint is not rate-limited and is not CORS-checked.
Curl Recipes
POST a contact form
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{
"name": "Jane Doe",
"email": "jane@example.com",
"service": "architecture",
"message": "Hello, I would like to discuss an engagement."
}'
POST a feedback form
curl -X POST http://localhost:8080/api/nuntius/feedback \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I love how the site uses Vue.js with zero runtime deps."
}'
POST a newsletter signup
curl -X POST http://localhost:8080/api/nuntius/newsletter \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{ "email": "jane@example.com" }'
Trigger the honeypot (bot)
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-d '{
"name": "Bot",
"email": "bot@spam.example",
"message": "buy cheap viagra click here",
"website": "https://spam.example"
}'
# → 200 {"ok": true}, no email sent, no log line about message sent
CORS preflight
curl -i -X OPTIONS http://localhost:8080/api/nuntius/contact \
-H "Origin: https://petrbalvin.org" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
Health check
curl -s http://localhost:8080/health | jq .
# → { "status": "ok", "forms": 3 }
Notes
- nuntius does not currently expose per-form metrics, request counters, or Prometheus endpoints. A future release may add
GET /metrics; for now, count rows in the JSONL log and grepjournalctl -u nuntiusfor request lines. - All request bodies are bounded by
ReadTimeout(15 s) andWriteTimeout(30 s). The full request body is read into memory byjson.NewDecoder(r.Body).Decode(&req); for a 5,000-rune message this is negligible. - Logs are emitted to stdout in JSON form via
log/slog. One line per request (method,path,status,duration_ms,ip) plus one line per sent / failed message and one line per honeypot hit. There is no PII in the log line beyond the client IP.