Submission
API.
Send to POST /f/{publicKey} on your form’s API origin. The public key accepts messages; reading them requires a signed-in workspace or a scoped management token.
1. Send a submission
The endpoint accepts application/json, application/x-www-form-urlencoded and multipart/form-data. No account credential is needed. Copy the exact URL from the connection guide.
curl 'https://api-staging.formkoi.com/f/YOUR_PUBLIC_FORM_KEY' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Idempotency-Key: YOUR_NEW_UNIQUE_REQUEST_ID' \
--data '{
"name": "Alex",
"email": "alex@example.com",
"message": "Could we talk about a project?",
"interests": ["Design", "Development"]
}'Replace the endpoint and request ID. This example assumes the form has no origin restriction and Turnstile is not enabled. A protected website must submit a fresh widget response; see the protection contract. Origin restrictions also apply to server calls.
For browser requests, use fetch(endpoint, { method: "POST", body: new FormData(form) }). Let the browser set the multipart boundary; do not set the multipart Content-Type yourself. Send neither cookies nor management credentials. Supported CORS headers are Content-Type and Idempotency-Key.
For native HTML and React examples, start with the website connection guide.
2. Field names and limits
JSON values may be strings, finite numbers, booleans or arrays of those values. Values become strings in the inbox. Repeated HTML field names become arrays. Nested objects and null values are not accepted.
| Part | Limit or behavior |
|---|---|
| Text body | 64 KiB; at most 50 named text fields and 100 values. |
| Values | 10,000 characters per text value; at most 20 scalar values per array. |
| Field names | 1–64 characters, starting with a letter or underscore. Letters, numbers, underscores, dots, brackets and hyphens are accepted. Reserved object-property names are rejected. |
email | Optional, but must be valid when supplied. Used as the owner notification’s Reply-To. |
name | Optional display name in the inbox. At least one non-empty business field or accepted file is required. |
| Files | Opt-in multipart uploads: three files, 5 MiB each, 10 MiB total. Text is still independently limited. See supported formats. |
The configured honeypot, _idempotency_key, access_key and cf-turnstile-response are control fields, removed from stored message content. The endpoint URL selects the form; access_key does not replace that URL. Fields named subject, redirect, to, cc or bcc are ordinary data and cannot change notification settings.
3. Receipt versus delivery
{
"success": true,
"submissionId": "MESSAGE_ID",
"duplicate": false,
"message": "Your message has been received."
}A JSON acknowledgement confirms durable receipt. Notifications and webhooks run asynchronously; inspect their histories in the workspace. Spam messages receive the same public acknowledgement and are held for review without owner notification.
A request advertising Accept: text/html gets a 303 redirect to the form’s configured HTTPS thank-you URL. With no redirect configured, it gets a built-in confirmation page with status 200. AJAX clients receive JSON unless they explicitly request HTML. Use a success message such as “Your message has been received,” rather than claiming email delivery is complete.
4. Idempotent retries
Send an Idempotency-Key header containing 8–128 letters, numbers, dots, underscores, colons or hyphens. A native form can instead submit a hidden _idempotency_key. Generate a unique value per intended submission, not one fixed value in the page template.
Keep the key when retrying unchanged data after an uncertain connection. An existing form/key/payload returns 200 with the original ID and duplicate: true, without a second charge or notification. Editing fields or files requires a new key. Reusing a key with different data returns 409 idempotency_conflict.
The fingerprint includes file names, field names, sizes, content hashes and file order. Turnstile responses do not affect it. A stored duplicate does not consume another challenge; if verification succeeded but storage did not, retry with a fresh challenge and the same unchanged-data key.
Permanent deletion removes its record. A later retry can become a new message. Requests without keys are separate submissions, and these guarantees do not apply to general management writes.
5. Errors and responses
{
"error": {
"code": "invalid_email",
"message": "Enter a valid email address.",
"requestId": "REQUEST_ID"
}
}Use the returned message to help the visitor and retain the request ID for support. Infrastructure failures can return a different body, so handle non-JSON responses too. HTML requests get a readable error page.
| Status | Meaning | Next step |
|---|---|---|
| 400 | Malformed JSON or form data | Check that JSON is an object and the body matches Content-Type. |
| 403 | Origin not allowed | Use a configured exact website origin. A restrictive allowlist also rejects a missing Origin. |
| 404 | Form unavailable | Check the full endpoint and environment. The form may have been deleted or its key rotated. |
| 409 | Paused form or idempotency conflict | Read error.code. Resume the form, or use a new key only if this is a new or edited message. |
| 413 / 415 | Body too large / unsupported format | Reduce the payload or use one of the supported content types. |
| 422 | Invalid fields, files or verification | Show the returned message, preserve input and correct the issue. Refresh an expired Turnstile check. |
| 429 | Rate, submission or storage limit | Honor Retry-After when present. Monthly and storage limits need owner action, such as upgrading or turning on extra usage; repeated retries will not resolve them. |
| 503 | Verification temporarily unavailable | Keep input and try later with a fresh check. The server cannot accept a new protected message without verification. |
Do not automatically retry every failure. Back off after transient limits, preserve an unchanged request key and show a useful retry action. Quota, configuration and validation errors need the relevant problem corrected first.
6. Message retention
Form settings let you keep new messages until you delete them, or set automatic expiry from 1–365 days after receipt. The default is no automatic expiry. The management API uses messageRetentionDays: an integer for automatic expiry, or null to turn it off. Each message records its own expiresAt timestamp.
Changes apply to new submissions only. Existing messages keep their original lifetime, even if you turn automatic expiry off. Moving a message to archive, reviewing spam or retrying the same submission does not extend its expiry. Download an export before expiry if you need your own copy.
At expiry the message is unavailable through the inbox, API, exports and attachment downloads. Pending notification and webhook work stops when a worker checks the expired message. A send already in progress may still complete. Scheduled cleanup permanently removes the stored message and its notification and webhook history, then removes its private files. Cleanup delays can extend physical storage; this is not an instantaneous erasure guarantee.
Usage counts, unsubscribe preferences and email suppressions remain independent of message history. Copies already emailed, exported or received by your webhook destination follow those systems’ retention. Provider event receipts and database recovery copies also have separate retention. Deletion cannot recall those copies. Once a message is physically removed, its idempotency record is removed too.