Skip to content

HTTP API

Posthorn exposes one HTTP endpoint per configured [[endpoints]] block. Endpoints come in two flavors selected by the auth config field:

  • Form mode (auth = "form", the default) — browser form posts; honeypot/Origin/CSRF defenses; redirect responses; documented in this page below.
  • API mode (auth = "api-key") — server-to-server JSON; Bearer auth; idempotency keys; per-request to_override; documented in the API mode feature page.

Plus two always-on operational endpoints on the same listener:

  • GET /healthz — 200 OK / body ok
  • GET /metrics — Prometheus text exposition

This page documents the form-mode wire format. For api-mode wire format, jump to API mode.

AspectRequired value
MethodPOST
PathAny configured form-mode path (e.g., /api/contact)
Content-Typeapplication/x-www-form-urlencoded or multipart/form-data
BodyForm-encoded key/value pairs

Other methods (GET, HEAD, PUT, DELETE, etc.) return 405 Method Not Allowed. Other content types return 400 Bad Request. JSON bodies on form-mode endpoints return 400; JSON bodies on api-mode endpoints are required (anything else returns 415 Unsupported Media Type).

POST /api/contact HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
name=Alice+Smith&email=alice%40example.com&message=Hello+world
POST /api/contact HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="name"
Alice Smith
------WebKitFormBoundary
Content-Disposition: form-data; name="email"
alice@example.com
------WebKitFormBoundary
Content-Disposition: form-data; name="message"
Hello world
------WebKitFormBoundary--

Both forms are handled by r.ParseForm() and produce the same internal representation.

When the submission is accepted and the transport returned success:

HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "ok",
"submission_id": "7f2c84d6-9b1e-4c2f-a3b8-1a2b3c4d5e6f"
}

When redirect_success is configured and the client prefers text/html:

HTTP/1.1 303 See Other
Location: /thank-you

Errors return either a JSON body (on 422 Unprocessable Entity only) or a plaintext body (every other non-2xx status), or redirect to redirect_error if one is configured and the client prefers text/html.

{
"error": "validation failed",
"code": "validation_failed",
"fields": {
"field_name": "field-specific error"
}
}

The structured fields map is the contract clients should program against — it enumerates every failing field in one response so a form UI can render all errors at once.

Plaintext error bodies (all other non-2xx)

Section titled “Plaintext error bodies (all other non-2xx)”

Other status codes return a one-line plaintext body (Content-Type: text/plain; charset=utf-8). The exact strings are documented per-status in Response codes. Programmatic callers should branch on status code, not body text — the strings are stable but not part of a versioned contract.

Promoting every error to structured JSON is on the future polish list. If you’d find it useful, open a feature request describing the use case.

StatusMeaningMode(s)
200 OKSubmission accepted and sent; also silent 200 on honeypot trigger (form mode) or idempotent replay (api mode); also 200 for dry-runboth
303 See OtherRedirect after success or errorform (when redirect_success/redirect_error configured)
400 Bad RequestMalformed request — wrong content type, unparseable bodyboth
401 UnauthorizedMissing/invalid Authorization: Bearerapi
403 ForbiddenOrigin/Referer rejected; or CSRF rejectedform
404 Not FoundPath not configuredboth
405 Method Not AllowedNot POSTboth
409 ConflictIdempotency-Key matches an in-flight requestapi
413 Payload Too LargeBody exceeded max_body_sizeboth
415 Unsupported Media TypeBody wasn’t application/jsonapi
422 Unprocessable EntityValidation failed (required field, email format, invalid to_override)both
429 Too Many RequestsRate limit exceeded (per IP in form, per API key in api)both
500 Internal Server ErrorTemplate render failedboth
502 Bad GatewayTransport terminal failure (or 10s timeout)both

See Response codes for the per-code error body shape and root causes.

Posthorn checks the Accept header to decide between JSON and redirect responses:

Accept headerRedirects configured?Response
application/json (preferred)(any)JSON
text/html (preferred)Yes (success or error path)303 redirect
text/html (preferred)NoJSON (fallback)
*/*(any)JSON
Missing(any)JSON

Most fetch-based form submitters set Accept: application/json and get JSON. Most plain browser form submissions set Accept: text/html and get a redirect.

const form = document.querySelector('form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = new FormData(form);
const res = await fetch('/api/contact', {
method: 'POST',
body: new URLSearchParams(data),
headers: { 'Accept': 'application/json' },
});
if (res.ok) {
// 200 → JSON: { status: "ok", submission_id: "..." }
const json = await res.json();
showSuccess(json.submission_id);
} else if (res.status === 422) {
// 422 → JSON: { error, code, fields: { fieldName: "required" | "invalid email format" } }
const json = await res.json();
showFieldErrors(json.fields);
} else if (res.status === 429) {
// Plaintext body; Posthorn doesn't emit a Retry-After header. Back off and retry.
showRateLimited();
} else {
// Plaintext body for 4xx/5xx other than 422. Use status code, not body text.
showGenericError(`HTTP ${res.status}`);
}
});
<form method="POST" action="/api/contact">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px">
<button type="submit">Send</button>
</form>

With redirect_success = "/thank-you" configured, a successful submission redirects to /thank-you. With redirect_error = "/contact?error=1", validation failures redirect back to the form.

Terminal window
# Success
curl -X POST http://localhost:8080/api/contact \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "name=Test User&email=test@example.com&message=hello"
# Multipart
curl -X POST http://localhost:8080/api/contact \
-F name="Test User" -F email=test@example.com -F message=hello
# Force JSON response
curl -X POST http://localhost:8080/api/contact \
-H "Accept: application/json" \
-d "name=...&..."

For server-to-server callers (Cloudflare Workers, cron jobs, payment handlers), configure an endpoint with auth = "api-key". The wire format is different: Bearer auth, JSON body, idempotency keys, per-request recipient override. Full reference: API mode.

A minimal example:

POST /api/transactional HTTP/1.1
Authorization: Bearer abc123
Content-Type: application/json
Idempotency-Key: reset:user-42:2026-05-16T20
{
"to_override": "alice@example.com",
"subject_line": "Reset your password",
"message": "https://app.example.com/reset/xyz"
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"status":"ok","submission_id":"..."}
FeatureWhen
File attachmentsv2
Webhook transport (outbound + lifecycle event forwarding)v2
Durable submission storage / retry queue across restartsv2
Suppression list + automatic unsubscribe injectionv2
Multi-tenant routing / per-endpoint fan-outv2