
# Form Submission API

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](/api/getting-started). 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.

1. From the form builder, click **Edit Flow**.
2. Open **Advanced Settings** and find **API submissions**.
3. Click **Enable API submissions**.
4. 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](#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:

```bash
curl https://YOUR-SUBDOMAIN.flowforms.app/api/v1/form/for_abc123/schema \
  -H "Authorization: Bearer ff_form_YOUR_FORM_KEY"
```

```json
{
  "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](/forms/field-types) |
| `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](#types-the-api-refuses) |
| `generated` | `true` for calculated and composite elements, which Flow Forms builds from the other answers; see [Generated Elements](#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](/forms/input-rules), when it has any |
| `visible_when` | The element's [condition](/forms/conditional-logic), when it has one, with each clause naming the element it watches |
| `max_rows`, `elements` | For repeating groups only; see [Repeating Groups](#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`:

```bash
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`:

```json
{
  "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.

- **`elements` is required**, even when empty. Leave it out and you get a `422`.
- **Names must be unique to be used.** If two elements share a name, the API asks you to use `form_element_id` instead 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:

```json
{
  "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):

```json
{"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](/forms/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 `notes` list 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](/forms/field-types#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:

```json
{
  "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:

```json
{
  "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].Item` is 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 `value` on 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_rows` is 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].Item` is 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](/forms/field-types#calculated) and [composite](/forms/field-types#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`:

```json
{"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 `422` and nothing is saved. The error names the elements it reads, keyed like any other: `Full Name`, or `Lines[2].Label` inside 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 `201` response instead of creating a second submission, with an `Idempotent-Replayed: true` header.
- **The same key with a different body** returns `422` with `"This Idempotency-Key was already used with a different payload."`
- **The same key while the first request is still saving** waits briefly, then returns `409` if 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.
