How it works
Every check a submission goes through, in order, and what each one can return.
Each post to /f/<formId> runs through the same pipeline. Cheap checks run first, so a flood or an unknown form costs almost nothing. The first check that fails decides the response.
Before the body is read
- 1Flood guard600 a minute per IP, across every form429 rate_limited
- 2Form lookupUnknown, deleted or disabled forms404 not_found
- 3Form rate limitsThe form's preset, per visitor and per form429 rate_limited
- 4PausedThe form is paused in the dashboard403 form_paused
Reading the post
- 5Parseurlencoded, multipart or JSON, UTF-8, size limits400, 413, 415 or 422
- 6API keyOnly when an Authorization header is sent403 forbidden
- 7Allowed domainsOrigin or Referer against the form's domainsSpam, or 403
- 8Human checkTurnstile token, when the check is on403, or a check page
Judging it
- 9ValidateAgainst the published fields, if any422 validation_failed
- 10ScoreHoneypot, timing, links, keywords, duplicatesSpam at 5 or more
- 11IdempotencyA repeated key replays the first answerReplay, 409 or 422
- 12Monthly usageFar over the workspace limit429 quota_exceeded
Stored
Submission saved
201 for code, 303 for browsers
- WebhooksSigned, retried for about 3 days
- Notification emailTo the form's recipients
- Dashboard and APILive, right away
Spam is stored too, and answered like a success, but sends nothing.
What each stage does
Rate limits before anything else
Rate limits run before the body is read. The flood guard (600 a minute per IP) runs even before the form lookup, so a flood never reaches the database. The form's own rules run right after the lookup. Rejected requests don't use up the allowance. Exact numbers are on Rate limits.
Parsing
The body is parsed as urlencoded, multipart or JSON. Text is decoded as UTF-8, normalized to NFC, CRLF becomes LF and control characters are removed. Names that start with _ are reserved instructions, such as _redirect and _subject, and are never stored as answers. See HTML forms.
Who sent it
An Authorization: Bearer header is optional. A secret key (ik_sk_) with the submissions:write scope skips the allowed-domains check and the Ikiform human check. A publishable key (ik_pub_) only works from the domains listed on the key. Without a key, the post is judged by its Origin.
Spam
Spam is never an error. A post that fails the allowed-domains policy in spam mode, or scores 5 or more, is stored with status spam and gets the same success response as a real one, so bots learn nothing. Spam sends no webhooks or emails and doesn't count toward usage. See Spam protection.
Store and deliver
The submission, its webhook deliveries and its notification email are written in one database batch, then handed to a queue. Webhook delivery and email never slow down the response, and a submission is never stored without its deliveries.
JSON or a redirect
The same endpoint answers browsers with pages and code with JSON. Ikiform decides per request:
- 1Accept includes application/jsonJSON
- 2The body is JSONJSON
- 3Sec-Fetch-Mode: navigateRedirect or page
- 4Any other Sec-Fetch-Mode, such as corsJSON
- 5No Sec-Fetch-Mode, Accept includes text/htmlRedirect or page
- 6Anything else, such as curlJSON
The first rule that matches wins.
In JSON mode success is 201 { "ok": true, "id": "sub_..." }. In page mode success is a 303 redirect, and errors render a hosted error page that lists field problems by label.
After a submission is stored
| What | When | Details |
|---|---|---|
| Webhooks | Seconds later, retried for about 3 days | Webhooks |
| Notification email | Seconds later, if notifications are on for the form | Capped at 50 emails per form per hour (10 for workspaces younger than 7 days), then one hourly digest |
| Dashboard | Immediately, live | Spam shows under the spam filter |
| REST API | Immediately | REST API |
Ids
| Id | Looks like | Notes |
|---|---|---|
| Form | frm_xxxxxxxxxxxxxxxx | frm_ plus 16 random characters. Public: it is in your form's action URL. |
| Submission | sub_01k7v2c9t3e8m5q4x6z0a1b2c3 | sub_ plus 26 characters, ordered by time. |
| Webhook delivery | dlv_... | Sent as webhook-id; stays the same across retries. |
| Request | req_... | Every response has an X-Request-Id header. Quote it when you contact support. |
{ "ok": true, "id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3" }