> For the complete documentation index, see [llms.txt](https://docs.powerfulform.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.powerfulform.com/public-api/submissions/create-a-submission.md).

# Create a submission

Post data into a form from your own server, with control over notifications, integrations and file uploads.

```
POST /forms/{id}/submit
```

Requires the `submissions:create` scope.

This is the one endpoint that writes real data into your store. It runs the same path a storefront submission runs: the answers are validated against the form, the submission counts toward your monthly allowance, notification emails go out, integrations fire, and a Shopify customer can be created. That is deliberate — two separate write paths into the same table is how data drifts apart.

## Request

```bash
curl -X POST https://app.powerfulform.com/api/public/v1/forms/10/submit \
  -H "X-PFB-API-KEY: pfb_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
        "data": {
          "name": "Tran Minh Khoa",
          "email": "khoa@example.com",
          "qty": "240",
          "region": "APAC"
        },
        "meta": { "locale": "en" },
        "options": { "send_notifications": true }
      }'
```

<table><thead><tr><th width="180">Body field</th><th width="110">Required</th><th>Notes</th></tr></thead><tbody><tr><td><code>data</code></td><td>Yes</td><td>The answers, keyed by field <code>key</code>. Must contain at least one entry.</td></tr><tr><td><code>meta.locale</code></td><td>No</td><td>Language code for the submission, up to 16 characters. Affects which translation is used in emails.</td></tr><tr><td><code>options</code></td><td>No</td><td>Switches for the side effects — see below.</td></tr></tbody></table>

The keys inside `data` are the `key` values from [`GET /forms/{id}`](/public-api/forms.md#the-field-schema) — not the labels.

## Options

<table><thead><tr><th width="250">Option</th><th width="110">Default</th><th>What it does</th></tr></thead><tbody><tr><td><code>send_notifications</code></td><td><code>true</code></td><td>Sends the admin notification and the customer autoresponder. Set <code>false</code> for a silent import.</td></tr><tr><td><code>run_integrations</code></td><td><code>true</code></td><td>Pushes to Google Sheets, Mailchimp, Klaviyo, Zapier and the rest. Set <code>false</code> to keep a backfill out of downstream systems.</td></tr><tr><td><code>create_customer</code></td><td><code>true</code></td><td>Creates or updates the Shopify customer when the form is configured to.</td></tr></tbody></table>

{% hint style="info" %}
Turn all three off when backfilling historical data. Leaving them on means every imported row emails your team and lands in your mailing list.
{% endhint %}

Two behaviours are fixed and cannot be switched on:

* **No captcha check.** The request is already authenticated by the API key; there is nobody to tick a box.
* **No page-view metric.** An API call is not somebody viewing your form. Counting it would distort the conversion rate on your analytics.

## Response

`201 Created`, with the stored submission in the same shape as [the submission object](/public-api/submissions.md#the-submission-object):

```json
{
  "data": {
    "id": 21675713,
    "form_id": 10,
    "status": "unread",
    "content": {
      "name": "Tran Minh Khoa",
      "email": "khoa@example.com",
      "qty": "240",
      "region": "APAC",
      "source": "api"
    },
    "files": [],
    "tags": [],
    "notes": null,
    "customer": null,
    "ticket": null,
    "is_hidden": false,
    "hidden_reason": null,
    "created_at": "2026-09-18T09:22:17Z",
    "updated_at": "2026-09-18T09:22:17Z"
  }
}
```

## Retrying safely

A network timeout does not tell you whether the write happened. Send an `Idempotency-Key` header and a retry cannot create a duplicate:

```bash
curl -X POST https://app.powerfulform.com/api/public/v1/forms/10/submit \
  -H "X-PFB-API-KEY: pfb_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-lead-88231" \
  -d '{ "data": { "name": "Tran Minh Khoa", "email": "khoa@example.com" } }'
```

A repeat of the same key within **24 hours** returns the original submission with `200` instead of `201`, and the header `Idempotent-Replay: true`. Use a value derived from the record you are sending — a CRM record id, an order number — not a fresh random string per attempt, or every retry creates a new submission.

## Uploading files

File fields take a value in `data` like any other field. There is no multipart request.

Send a base64 data URI:

```json
{
  "data": {
    "name": "Tran Minh Khoa",
    "receipt": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
  }
}
```

or an `http(s)` URL the server can fetch:

```json
{
  "data": {
    "name": "Tran Minh Khoa",
    "receipt": "https://files.example.com/receipts/88231.pdf"
  }
}
```

### Setting the stored filename

A bare URL or data URI leaves the app to work the filename out for itself — from the last segment of the URL, or `file.<ext>` when there is nothing to go on. To name the file yourself, send an object instead of a string:

```json
{
  "data": {
    "receipt": { "name": "invoice-88231.pdf", "url": "https://files.example.com/r/88231" }
  }
}
```

The object takes the file in either **`url`** or **`data`**, and the name in **`name`**:

<table><thead><tr><th width="120">Key</th><th width="110">Required</th><th>Value</th></tr></thead><tbody><tr><td><code>url</code></td><td>one of the two</td><td>An <code>http(s)</code> URL the server fetches.</td></tr><tr><td><code>data</code></td><td>one of the two</td><td>A <code>data:&#x3C;mime>;base64,…</code> URI.</td></tr><tr><td><code>name</code></td><td>no</td><td>The filename to store and show. Any directory part is stripped and it is cut to 200 characters. Omit it and the app falls back to guessing.</td></tr></tbody></table>

{% hint style="info" %}
`name` sets the stored filename only — it does **not** decide the file type. The extension still comes from the URL path or the mime type of the data URI, so `{"name": "invoice.pdf", "url": "https://files.example.com/r/88231"}` with no extension and an unhelpful content type is still rejected.
{% endhint %}

### Several files, and the rules

For a field that accepts several files, send an array — of strings, of objects, or mixed. A field that accepts one file rejects an array of more than one.

The file is checked against that field's own settings before it is stored — the allowed extensions you configured and your plan's maximum upload size. Anything rejected comes back as `422 invalid_file_field` with the field key in `field`.

A few practical points:

* **The extension must be knowable.** A URL ending in `.pdf` works; so does a data URI with a recognised mime type. A URL with no extension and an unhelpful content type is rejected rather than guessed at.
* **URLs must be publicly reachable.** Addresses that resolve to private, internal or loopback ranges are refused.
* **Redirects are not followed.** Give the final URL.
* **The fetch times out after 15 seconds.**

Once stored, the file comes back in `files` with a stable `download_url` — see [Download a file](/public-api/submissions/download-a-file.md).

## Errors

<table><thead><tr><th width="120">Status</th><th width="230">Code</th><th>Cause</th></tr></thead><tbody><tr><td>403</td><td><code>insufficient_scope</code></td><td>The key is missing <code>submissions:create</code>. Note that <code>submissions:update</code> does not cover creating.</td></tr><tr><td>404</td><td><code>not_found</code></td><td>No such form, or it belongs to another store.</td></tr><tr><td>409</td><td><code>form_inactive</code></td><td>The form's status is Draft. A draft form is not on your storefront either, so the API matches that.</td></tr><tr><td>415</td><td><code>unsupported_media_type</code></td><td>The body arrived without <code>Content-Type: application/json</code>.</td></tr><tr><td>422</td><td><code>validation_failed</code></td><td>The answers failed the form's own rules. <code>field</code> is prefixed <code>data.</code> — for example <code>data.email</code>. Also returned when the form's total submission limit has been reached.</td></tr><tr><td>422</td><td><code>invalid_file_field</code></td><td>A file value could not be read, downloaded or accepted.</td></tr><tr><td>429</td><td><code>rate_limited</code></td><td>The <strong>submit</strong> bucket is spent — 30 per minute. See <a href="/public-api/requests-and-responses.md#rate-limits">Rate limits</a>.</td></tr></tbody></table>

```json
{
  "errors": [
    { "code": "validation_failed", "field": "data.name",  "message": "The name field is required." },
    { "code": "validation_failed", "field": "data.email", "message": "The email must be a valid email address." }
  ]
}
```

<br>

## Next steps

* [Submissions](/public-api/submissions.md) — read back what you created.
* [Update a submission](/public-api/submissions/update-a-submission.md) — add tags and notes afterwards.
* [Errors](/public-api/errors.md) — how to handle each status.
