Skip to content

Submit endpoint reference: POST /s/{formId}

Everything about the Postbag submit endpoint: accepted content types, control fields (_redirect, _gotcha, _test, _idempotency, _subject), responses, CORS, rate limits, the 256 KB body limit, and how each outcome is stored.

Updated · Markdown

POST /s/{formId} is the hot path. It is public (no auth), boring on purpose, and never makes a third-party network call except optional Turnstile verification.

Content types

Content-TypeNotes
application/x-www-form-urlencodedDefault for HTML forms. Repeated keys become arrays.
multipart/form-dataText fields only in this phase; a file part returns 415 unsupported_media_type with a hint.
application/jsonAny JSON object. Nested objects are stored as-is.

Maximum body size is 256 KB; larger bodies return 413 payload_too_large.

Control fields

Fields starting with _ are stripped from data and interpreted:

FieldEffect
_redirectWhere to send an HTML (non-JS) post after a 303. Overrides the form’s redirect_url setting.
_gotchaThe honeypot (rename via settings.honeypot_field). A non-empty value stores the submission as spam.
_testtrue stores a test submission: routed like any other, excluded from quotas, and the response includes deliveries[] ids to poll.
_idempotencySame as the Idempotency-Key header: unique per form; a repeat returns the original submission with idempotent: true.
_subjectOptional subject hint for email destinations.

Responses

ClientSuccessNotes
JSON / fetch200 { ok, submission_id, status, deliveries? }status is received, quarantined or spam. Spam and quarantine still answer 200; bots learn nothing.
HTML form303 to _redirect → form redirect_url → hosted thanks page
Paused form202, stored, not routed
Unknown form404 not_found
Rate limited429 rate_limited (and stored as quarantined)Retry-After set.

Every error body is { "error": { "code", "message", "hint", "docs" } }. See Error codes.

What the endpoint checks, in order

  1. Resolve the form (cached). Unknown → 404. paused → stored, not routed.
  2. Parse the body and strip control fields.
  3. Cheap checks that all store anyway with a status: honeypot → spam; origin not in settings.allowed_originsquarantined/origin_rejected; over settings.rate_limitquarantined/rate_limited; Turnstile failed → quarantined/turnstile_failed.
  4. Schema: in enforce and managed modes, validate; a violation stores quarantined/schema_violation and raises a drift event. In observe, compare to the current schema if any and raise drift on differences. Never blocks.
  5. One transaction: insert the submission, plan one delivery per applicable route (direct and via streams, respecting enabled, window, quality), write submission.received.
  6. Respond, then NOTIFY postbag_deliveries so an idle worker wakes immediately.

CORS

settings.allowed_origins doubles as the CORS allowlist for fetch-based submissions. Empty means any origin may post (and the Access-Control-Allow-Origin echoes the request origin). GET /s/{formId}/schema (managed forms) is always CORS-open.

Metadata stored with each submission

ip (from CF-Connecting-IP, then the first X-Forwarded-For hop, then the socket), user_agent, origin, referer, country (from Cloudflare when present), received_at, content_type, and the form schema version validated against, if any.

Your first form is three minutes away.

Sign up, get a submit URL, point a form at it. The first submission lands in your inbox and your email. Everything else appears when you need it.