The form submission API lets another system submit to one Flow Forms form. Use it when you have built your own front end, such as a page on your website, a kiosk app, or an intake step in another product, and want each submission to land in Flow Forms and run the form's workflow as if someone had filled out the form itself.
It is separate from the account API. Each form has its own key, the key works only for that form, and all it can do is read the form's schema and create submissions. Nothing else in the account is reachable with it.
Turning It On
The API takes anonymous forms only, because a submission made with a form key has no submitter. If the form is not anonymous yet, turn on Anonymous Form in its settings and click Save first.
- From the form builder, click Edit Flow.
- Open Advanced Settings and find API submissions.
- Click Enable API submissions.
- Copy the key immediately. It is shown once and cannot be retrieved later.
Once the API is on, the same section shows the form's Schema and Submit URLs, ready to copy, and the last four characters of the key so you can tell which key is in use.
- Regenerate key issues a new key and shows it once. The old key stops working at once, so update your integration before or right after you regenerate.
- Turn off disables the API and deletes the key. The schema and submit URLs then return
404. Turning it back on issues a new key. - Making the form non-anonymous turns the API off and deletes its key, for the same reason the API needs an anonymous form.
Graduated forms (forms attached to a flow step) do not offer API submissions at all; see Forms the API Refuses.
Authentication
Send the key on every request, in either header:
Authorization: Bearer ff_form_YOUR_FORM_KEY
X-FF-Form-Key: ff_form_YOUR_FORM_KEY
Form keys always start with ff_form_. A missing key, a regenerated key, or a key from a different form returns 401 with {"message": "Unauthenticated."}.
Treat the key as a server-side secret. Anyone holding it can create submissions on the form, so keep it out of browser JavaScript and mobile app bundles and call the API from your own server.
Endpoints
Both endpoints live under your account's address, with the form's for_… ID in the path:
| Method and path | What it does |
|---|---|
GET /api/v1/form/{form_id}/schema |
Describes the form's elements and what the API accepts for each |
POST /api/v1/form/{form_id}/submission |
Creates a submission |
So a full submit URL looks like https://YOUR-SUBDOMAIN.flowforms.app/api/v1/form/for_abc123/submission. The URLs shown under API submissions are the exact ones to use.
Reading the Schema
Fetch the schema before you build the request, and again whenever the form changes:
curl https://YOUR-SUBDOMAIN.flowforms.app/api/v1/form/for_abc123/schema \
-H "Authorization: Bearer ff_form_YOUR_FORM_KEY"
{
"form_id": "for_abc123",
"name": "Issue Report",
"is_open": true,
"schema_version": "3f9a1c…",
"elements": [
{
"id": 101,
"name": "Issue Type",
"type": "radio",
"required": true,
"multiple": false,
"supported": true,
"options": [
{"value": "bug", "label": "Bug Report"},
{"value": "enhancement", "label": "Feature Request"}
]
},
{
"id": 102,
"name": "Steps",
"type": "textarea",
"required": false,
"multiple": false,
"supported": true,
"visible_when": {
"condition": "Issue Type = bug",
"clauses": [
{"element_id": 101, "element": "Issue Type", "operator": "is equal to", "value": "bug", "connective": "and"}
]
}
},
{
"id": 103,
"name": "Signature",
"type": "signature",
"required": false,
"multiple": false,
"supported": false
}
]
}
Each element lists:
| Key | Meaning |
|---|---|
id |
The element's numeric ID, which you can send as form_element_id |
name |
The element's name, which you can send as name |
type |
The field type |
required |
Whether the element must be answered while it is shown |
multiple |
true for checkboxes and for file fields that take more than one file |
supported |
false when the API refuses answers for this element; see Types the API Refuses |
generated |
true for calculated and composite elements, which Flow Forms builds from the other answers; see Generated Elements |
generated_from |
For generated elements: the IDs of the elements the value is built from. Left out when it reads no element |
options |
For choice fields: each option's value and label. Present only when the element has options |
allows_other |
true when the element also takes text that is not one of its options |
rules |
The element's input rules, when it has any |
visible_when |
The element's condition, when it has one, with each clause naming the element it watches |
max_rows, elements |
For repeating groups only; see Repeating Groups |
Keys with no value are left out rather than sent empty. Content elements take no answer and do not appear in the schema at all.
is_open is false while the form's schedule has it closed or not yet open. Submissions are refused until it opens.
Submitting
POST the answers as an elements list. Each entry names one element, by name or by form_element_id, and carries its value:
curl -X POST https://YOUR-SUBDOMAIN.flowforms.app/api/v1/form/for_abc123/submission \
-H "Authorization: Bearer ff_form_YOUR_FORM_KEY" \
-H "Content-Type: application/json" \
-d '{
"elements": [
{"name": "Full Name", "value": "Ada Lovelace"},
{"form_element_id": 101, "value": "bug"},
{"name": "Toppings", "value": ["cheese", "ham"]}
]
}'
A created submission returns 201:
{
"submission_id": "sub_xyz789",
"url": "https://YOUR-SUBDOMAIN.flowforms.app/submission/sub_xyz789"
}
The submission is anonymous and goes through the same steps as a submission from the form itself: it is saved, its workflow starts, and its notifications go out.
elementsis required, even when empty. Leave it out and you get a422.- Names must be unique to be used. If two elements share a name, the API asks you to use
form_element_idinstead and lists the matching IDs. IDs survive a rename, so they are the safer choice for a long-lived integration. - Elements you leave out are left blank. You only need to send what you have.
Choice Fields
Select, radio, and checkbox answers must be one of the element's options, just as a person filling out the form can only pick from those. You can send an option's value or its label; a label is stored as its option's value. A label that more than one option shares is refused, so send the value instead.
An answer that is not an option returns 422 and lists the valid values:
{
"message": "'medium' is not an option for 'Size'. Valid values: small, large",
"errors": {"Size": ["'medium' is not an option for 'Size'. Valid values: small, large"]}
}
Elements with allows_other take any text, and so do autocompletes.
Checkboxes take a list. Send "value": ["cheese", "ham"], and every item must be an option. Only checkboxes accept a list; a list on any other element is refused.
Files
File fields take a files list instead of a value. Each file is an object with a filename and its content as base64 (a data: URI works too):
{"name": "Resume", "files": [{"filename": "resume.pdf", "content": "JVBERi0xLjEg…"}]}
The field's own rules on file types and size apply, and a field that takes one file refuses two. URLs and upload_id values are refused: the file itself must be in the request.
Conditions
Conditional logic applies to API submissions the same way it applies on the form, judged on the answers you send.
-
Answers to hidden elements are discarded, not stored. The submission is still created, and the response carries a
noteslist saying what was dropped and why:json{ "submission_id": "sub_xyz789", "url": "…", "notes": [ "Discarded 'Steps': it is hidden for the submitted values (condition: Issue Type = bug), so a person filling out the form would never see it. Omit hidden elements." ] }Treat a note as a sign that your integration is sending something it shouldn't.
-
Required applies only while an element is shown. A required element hidden by your answers can be left out. One that your answers show must be answered, or the submission is refused:
json{ "message": "Missing required elements: Full Name", "errors": {"Full Name": ["This field is required."]} }
A blank answer, such as an empty string or an empty checkbox list, counts as no answer.
Repeating Groups
A group takes its answers as rows. Send the group's name (or form_element_id) with a rows list, where each row is a list of the group's fields, in the same {name, value} shape as everything else:
{
"elements": [
{"name": "Location", "value": "Arrowhead"},
{"name": "Lines", "rows": [
[{"name": "Item", "value": "5730"}, {"name": "Quantity", "value": "2"}],
[{"name": "Item", "value": "4636"}, {"name": "Quantity", "value": "3"}]
]}
]
}
In the schema, a group lists its fields under its own elements, along with max_rows, the most rows it takes in one request:
{
"id": 201,
"name": "Lines",
"type": "group",
"required": false,
"multiple": false,
"supported": true,
"max_rows": 5,
"elements": [
{"id": 202, "name": "Item", "type": "text", "required": true, "multiple": false, "supported": true},
{"id": 203, "name": "Photo", "type": "file", "required": false, "multiple": false, "supported": false}
]
}
-
Rows are numbered from 1, as they are on the form, and errors name the row:
Lines[2].Itemis the Item field in the second row.json{ "message": "Missing required elements: Lines[2].Item", "errors": {"Lines[2].Item": ["This field is required."]} } -
Group fields go only inside rows. Sending a group field at the top level, a
valueon the group itself, or a field from outside the group inside a row is refused. -
Send each group once, with all its rows in one entry. Every row needs at least one answer.
-
max_rowsis the group's own Maximum Rows, capped at 200. A group with no maximum still takes at most 200 rows per request. -
A group with a required field needs at least one row. Leaving the group out, or sending it with no rows, is checked as an empty first row, so you get
Lines[1].Itemis required. -
Conditions are judged row by row. A field hidden by the answers in its own row is discarded from that row, with a note naming the row. If the whole group is hidden, all its rows are discarded.
Some groups cannot take rows yet, and the schema marks them supported: false:
- File fields inside a group. Files cannot be sent in rows yet.
- A group inside another group.
- A group that copies its rows from another form.
Generated Elements
Calculated and composite elements work out their value from the other answers, as they do on the form. The schema marks them generated: true and lists the elements they read in generated_from:
{"id": 104, "name": "Full Name", "type": "composite", "required": true, "multiple": false, "supported": true, "generated": true, "generated_from": [105, 106]}
-
Leave them out of the request. Flow Forms builds them from your answers once the submission is saved, calculations first, so a composite can read a calculated value. Sending a value for one returns
422:json{ "message": "These elements cannot be submitted through the form API: Full Name", "errors": {"Full Name": ["composite elements are generated from the other answers, so leave them out."]} } -
Inside a repeating group, each row's value is built from that row's answers. They are left out of rows too.
-
A generated element hidden by its condition gets no value, as on the form, and a calculation or composite that reads it leaves it out. Inside a group this is judged row by row.
-
A required generated element must come out with a value. It is checked after it is built, and if your answers leave it blank, the submission is refused with a
422and nothing is saved. The error names the elements it reads, keyed like any other:Full Name, orLines[2].Labelinside a group.json{ "message": "Missing required elements: Full Name", "errors": {"Full Name": ["This field is required, and is built from First Name, Last Name, which left it blank."]} }As with any element, it is required only while it is shown.
Types the API Refuses
Some elements need the Flow Forms form itself to capture their value. The API refuses answers for them, and the schema marks them supported: false:
- Signature
- Payment
- Notion
- Boundary Finder
- Calculated and composite elements linked to a data point
Sending an answer to one returns a 422 naming it.
If one of these elements is required and shown, the form cannot accept API submissions while it is shown, and the error says so. Make it optional, or put it behind a condition your API submissions don't meet.
Detecting Form Changes
The schema's schema_version is a fingerprint of everything the API checks your request against: element names, IDs, types, options, required flags, rules, and conditions. Renaming a field, adding an option, or reordering fields changes it; changes to the workflow, notifications, or styling leave it alone.
Store the version alongside your mapping and send it back on each submission:
X-FF-Schema-Version: 3f9a1c…
If the form has changed since, the submission is still created, and the response adds "schema_changed": true. That is your cue to fetch the schema again and check your mapping before the change breaks a request. Leave the header out and the check is skipped.
Forms with a repeating group report schema_changed once if you stored their schema_version before the API accepted rows, because the schema now lists each group's fields under the group. Fetch the schema, store the new version, and the flag goes away.
Forms with a calculated or composite element also report schema_changed once if you stored their schema_version before the API built these elements, because the schema now marks them generated and lists what they read.
Retrying Safely
Send an Idempotency-Key header, any string unique to the submission such as your own order or record ID, to make a retry safe:
Idempotency-Key: order-42
- The same key and the same body within 24 hours returns the original
201response instead of creating a second submission, with anIdempotent-Replayed: trueheader. - The same key with a different body returns
422with"This Idempotency-Key was already used with a different payload." - The same key while the first request is still saving waits briefly, then returns
409if the first request has not finished. Retry after a short pause.
Only successful submissions are remembered. A request refused with a 422 can be corrected and retried under the same key.
Forms the API Refuses
The submit endpoint returns 403 with a message for forms it cannot submit to:
| Message | Why |
|---|---|
Only anonymous forms can be submitted through the form API. |
The form needs a named submitter, which a form key does not carry |
Graduated forms cannot be submitted through the form API. |
A graduated form's submission belongs to a parent submission the API cannot name |
This form's workflow asks the submitter to choose an approver, which the form API cannot do. |
The first flow step lets the submitter pick an approver |
This form is not open yet. |
The form's schedule has not opened it yet |
This form does not exist or has been disabled. |
The form's schedule has closed it, or it has been disabled |
Errors
| Status | Body |
|---|---|
| 401 | {"message": "Unauthenticated."}: missing or wrong key |
| 403 | {"message": "…"}: the form cannot take API submissions right now; see above |
| 404 | {"message": "Not found."}: no such form, or the API is turned off |
| 409 | An earlier request with the same Idempotency-Key is still saving |
| 422 | {"message": "…", "errors": {"Field": ["what is wrong"]}}: the request was refused and nothing was saved |
| 429 | Too many requests; see rate limiting below |
errors is keyed by element name, or by Group[row].Field for answers inside a repeating group, so you can map each message back to the input that caused it.
Rate Limiting
Each form allows 60 requests per minute from one IP address, counting schema and submit requests together. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a 429 includes a Retry-After header with the seconds to wait. This limit is separate from the account API's.