Submission ingest
POST /v1/f/:slug — content types, file uploads, idempotency, response shapes and quota behaviour.
POST https://api.formflow.cc/v1/f/:slug
The endpoint your forms post to. It is public and CORS-open (Access-Control-Allow-Origin: *),
so it works from any site without a proxy. Posting with a ff_sub_ bearer key is
optional — if you send one, it must belong to the form’s account, which lets you
lock server-to-server pipelines to a revocable credential.
Content types
Three request body formats are accepted:
| Content-Type | Notes |
|---|---|
application/x-www-form-urlencoded | What a plain HTML <form> sends. |
multipart/form-data | Required for file uploads. |
application/json | For programmatic posts. Non-string values are stored JSON-stringified. |
Field names are stored as-is, except control fields (the form’s honeypot field and
cf-turnstile-response), which are stripped from the stored payload.
# Plain form post
curl https://api.formflow.cc/v1/f/contact-form \
-d "email=ada@example.com" \
-d "message=Hello there"
# JSON with an idempotency key
curl https://api.formflow.cc/v1/f/contact-form \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d1f8c1e-7c70-4a4e-9e57-2f6b1f3a9b0d" \
-d '{"email":"ada@example.com","message":"Hello there"}'
File uploads
Send files as multipart/form-data parts. They’re streamed into R2 storage and
listed on the submission (the console and webhook payloads link to them).
curl https://api.formflow.cc/v1/f/job-applications \
-F "email=ada@example.com" \
-F "resume=@cv.pdf"
Per-file size limits come from your plan (e.g. 25 MB on Pro, 50 MB on Team; the
free plan doesn’t include uploads). Oversized files are rejected up front with
413 file-too-large; plans without uploads get 413 uploads-not-allowed.
Submissions caught by the spam layer never store their files.
Idempotency
Pass an Idempotency-Key header (any unique string — a UUID is ideal). Keys are
scoped per form and remembered for 24 hours. Replaying a known key skips
processing entirely and returns:
{ "id": "sub_8f3k2j", "status": "received", "idempotent_replay": true }
This makes client retries (flaky mobile networks, double-clicked submit buttons) safe: at most one submission is created.
Responses
Created — 201
{
"id": "sub_8f3k2j",
"received_at": "2026-06-10T10:23:00.000Z",
"status": "received",
"files": 1
}
Caught as spam — 200
{ "ok": true, "status": "accepted" }
Honeypot hits get a deliberately benign success response so bots learn nothing —
see Anti-spam. The submission is stored with
status: "spam" and triggers no emails, webhooks or file storage.
Errors are application/problem+json (overview):
| Status | Problem | When |
|---|---|---|
400 | bad-body | Body couldn’t be parsed as form data or JSON. |
402 | quota-exceeded | Monthly submission quota used up (see below). |
403 | form-not-live | Form is paused or draft. |
403 | invalid-key | A ff_sub_ key was sent but is invalid, revoked, or belongs to another account. |
403 | turnstile-failed | Turnstile enabled, token missing or invalid. |
404 | form-not-found | No form with this slug. |
413 | file-too-large / uploads-not-allowed | Attachment limits, per plan. |
429 | rate-limited | Per-IP per-form rate limit (default 60 / 10 min). |
Quota — 402
Each plan includes a monthly submission allowance, counted per account over the calendar month (UTC). Once it’s used, ingest responds:
{
"type": "https://formflow.cc/problems/quota-exceeded",
"title": "Monthly quota exceeded",
"status": 402,
"detail": "This account has used its monthly submission quota. Upgrade the plan to keep receiving submissions.",
"limit": 500
}
The check runs before any parsing or storage work, so over-quota posts are cheap
to reject and nothing is partially stored. Spam-flagged submissions don’t count
toward the quota. Current usage is always visible in the console and at
GET /v1/usage.