---
title: "REST API reference"
description: "Every endpoint of the quickforms REST API, with request and response examples."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# REST API reference

Base URL `https://YOUR-QUICKFORMS-HOST/api/v1`. Everything you can do in the dashboard, the API can do too.

## Authentication

Create an API key on the API keys page of your team in the dashboard and send it as `Authorization: Bearer <api_key>`. API keys belong to a team, and every request only sees that team's forms, submissions, deliveries, destinations, partial submissions and mail providers. Send `Accept: application/json`.

- `401` for a missing or invalid API key, `404` for anything outside the API key's team, `422` for validation errors.
- Requests are rate limited to 120 a minute per API key.
- `403` when the API key lacks the ability or the user's role doesn't allow it. See [API key abilities](#key-abilities).
- `POST /api/v1/forms/generate` is also limited to 30 generated forms an hour per user by default (`QUICKFORMS_FORM_BUILDER_PER_HOUR`), and returns `429` past that.
- The forms, submissions and partial submissions lists are paginated with `page` and `per_page` (up to 100, default 25). Personal links are paginated with `per_page` up to 200, default 50.

### Key abilities

Choose an API key's abilities when you create it on the API keys page. New API keys have `read` and `write` by default.

| Ability | Allows |
| --- | --- |
| `read` | `GET` endpoints, the destination preview dry run and read-only MCP tools. |
| `write` | Every other endpoint and MCP tool: creating, changing and deleting forms, destinations, links, mail providers and submissions. |
| `proxy` | [Server-side proxy mode](https://docs.getquickforms.com/tracking/#server-side-proxy). Only API keys with this ability are trusted to forward visitor details, and the API key's user must still be on the team and able to manage forms. |

An API key can never do more than its user's team role allows. Members are read-only, so a member's API key gets a `403` on writes even with the `write` ability. Admins and owners can write. A `403` comes back when the API key lacks the ability or the role doesn't allow the action.

## Endpoints

Every `/api/v1` endpoint. `PATCH /api/v1/forms/{form}` also accepts `PUT`. For the public endpoints that forms post to, see [public endpoints](#public-endpoints).

| Method | Path | Section |
| --- | --- | --- |
| `GET` | [`/api/v1/me`](#get-apiv1me) | Account |
| `GET` | [`/api/v1/forms`](#get-apiv1forms) | Forms |
| `POST` | [`/api/v1/forms`](#post-apiv1forms) | Forms |
| `GET` | [`/api/v1/form-templates`](#get-apiv1form-templates) | Forms |
| `POST` | [`/api/v1/forms/generate`](#post-apiv1formsgenerate) | Forms |
| `GET` | [`/api/v1/forms/{form}`](#get-apiv1formsform) | Forms |
| `PATCH` | [`/api/v1/forms/{form}`](#patch-apiv1formsform) | Forms |
| `POST` | [`/api/v1/forms/{form}/publish`](#post-apiv1formsformpublish) | Forms |
| `DELETE` | [`/api/v1/forms/{form}/draft`](#delete-apiv1formsformdraft) | Forms |
| `POST` | [`/api/v1/forms/{form}/webhook-secret/rotate`](#post-apiv1formsformwebhook-secretrotate) | Forms |
| `POST` | [`/api/v1/forms/{form}/sandbox-key/rotate`](#post-apiv1formsformsandbox-keyrotate) | Forms |
| `DELETE` | [`/api/v1/forms/{form}`](#delete-apiv1formsform) | Forms |
| `POST` | [`/api/v1/forms/{form}/archive`](#post-apiv1formsformarchive) | Forms |
| `POST` | [`/api/v1/forms/{form}/restore`](#post-apiv1formsformrestore) | Forms |
| `PUT` | [`/api/v1/forms/by-key/{key}`](#put-apiv1formsby-keykey) | Forms as code |
| `GET` | [`/api/v1/forms/by-key/{key}`](#get-apiv1formsby-keykey) | Forms as code |
| `POST` | [`/api/v1/forms/{form}/links`](#post-apiv1formsformlinks) | Personal links |
| `GET` | [`/api/v1/forms/{form}/links`](#get-apiv1formsformlinks) | Personal links |
| `GET` | [`/api/v1/links/{link}`](#get-apiv1linkslink) | Personal links |
| `DELETE` | [`/api/v1/links/{link}`](#delete-apiv1linkslink) | Personal links |
| `POST` | [`/api/v1/forms/{form}/test-submissions`](#post-apiv1formsformtest-submissions) | Submissions |
| `GET` | [`/api/v1/forms/{form}/submissions`](#get-apiv1formsformsubmissions) | Submissions |
| `GET` | [`/api/v1/forms/{form}/export`](#get-apiv1formsformexport) | Submissions |
| `GET` | [`/api/v1/submissions/{submission}`](#get-apiv1submissionssubmission) | Submissions |
| `POST` | [`/api/v1/submissions/{submission}/review`](#post-apiv1submissionssubmissionreview) | Submissions |
| `POST` | [`/api/v1/submissions/{submission}/confirm`](#post-apiv1submissionssubmissionconfirm) | Submissions |
| `POST` | [`/api/v1/submissions/{submission}/resend-confirmation`](#post-apiv1submissionssubmissionresend-confirmation) | Submissions |
| `GET` | [`/api/v1/submissions/{submission}/deliveries`](#get-apiv1submissionssubmissiondeliveries) | Deliveries |
| `GET` | [`/api/v1/deliveries/{delivery}`](#get-apiv1deliveriesdelivery) | Deliveries |
| `POST` | [`/api/v1/deliveries/{delivery}/redeliver`](#post-apiv1deliveriesdeliveryredeliver) | Deliveries |
| `GET` | [`/api/v1/destination-types`](#get-apiv1destination-types) | Destinations |
| `GET` | [`/api/v1/forms/{form}/destinations`](#get-apiv1formsformdestinations) | Destinations |
| `POST` | [`/api/v1/forms/{form}/destinations`](#post-apiv1formsformdestinations) | Destinations |
| `GET` | [`/api/v1/destinations/{destination}`](#get-apiv1destinationsdestination) | Destinations |
| `PATCH` | [`/api/v1/destinations/{destination}`](#patch-apiv1destinationsdestination) | Destinations |
| `DELETE` | [`/api/v1/destinations/{destination}`](#delete-apiv1destinationsdestination) | Destinations |
| `POST` | [`/api/v1/destinations/{destination}/preview`](#post-apiv1destinationsdestinationpreview) | Destinations |
| `POST` | [`/api/v1/hooks`](#post-apiv1hooks) | Automation hooks |
| `DELETE` | [`/api/v1/hooks/{hook}`](#delete-apiv1hookshook) | Automation hooks |
| `GET` | [`/api/v1/forms/{form}/sample`](#get-apiv1formsformsample) | Automation hooks |
| `GET` | [`/api/v1/forms/{form}/partials`](#get-apiv1formsformpartials) | Partial submissions and analytics |
| `GET` | [`/api/v1/partials/{partial}`](#get-apiv1partialspartial) | Partial submissions and analytics |
| `DELETE` | [`/api/v1/partials/{partial}`](#delete-apiv1partialspartial) | Partial submissions and analytics |
| `GET` | [`/api/v1/forms/{form}/funnel`](#get-apiv1formsformfunnel) | Partial submissions and analytics |
| `GET` | [`/api/v1/forms/{form}/analytics`](#get-apiv1formsformanalytics) | Partial submissions and analytics |
| `GET` | [`/api/v1/mail-providers`](#get-apiv1mail-providers) | Mail providers |
| `POST` | [`/api/v1/mail-providers`](#post-apiv1mail-providers) | Mail providers |
| `GET` | [`/api/v1/mail-providers/{mailProvider}`](#get-apiv1mail-providersmailprovider) | Mail providers |
| `DELETE` | [`/api/v1/mail-providers/{mailProvider}`](#delete-apiv1mail-providersmailprovider) | Mail providers |

## Account

### `GET /api/v1/me`

The user, team and API key behind the current request. Handy for checking an API key works.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/me \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "user": { "id": "01jqk7m2x4v8n3p5r6s9t0w1ya", "name": "Ada Lovelace", "email": "ada@example.com" },
    "team": { "id": "01jqk7m3b6c2d8e4f5g7h9j0kb", "name": "Acme", "slug": "acme" },
    "token": { "id": "01jqk8a1c3d5e7f9g2h4j6k8mc", "name": "CI", "last_used_at": "2026-11-06T10:00:00.000000Z" }
  }
}
```

## Forms

### `GET /api/v1/forms`

The team's forms, newest first, with submission counts.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/forms?per_page=10 \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": [
    { "id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "name": "Contact", "mode": "live", "status": "published", "submissions_count": 61, "...": "..." }
  ],
  "links": { "first": "...?page=1", "last": "...?page=3", "prev": null, "next": "...?page=2" },
  "meta": { "current_page": 1, "from": 1, "last_page": 3, "per_page": 25, "to": 25, "total": 61 }
}
```

### `POST /api/v1/forms`

Create a form. `name` and `schema` are required, unless you pass `template`. Optional: `key` (a unique key for [forms as code](https://docs.getquickforms.com/forms-as-code/), 1 to 64 lowercase letters, numbers, dashes and underscores), `template` (a key from [`GET /api/v1/form-templates`](#get-apiv1form-templates): the template's fields and settings are used and anything else you pass overrides them, see [creating forms](https://docs.getquickforms.com/creating-forms/#create-a-form-from-a-template); an unknown key is a 422 on `template`), `description` (what the form is for, up to 1000 characters, used as context by the [spam filter](https://docs.getquickforms.com/spam-filter/)), `mode` (`live` or `sandbox`), `is_active`, `webhook_url`, `mail_provider_id`, `settings` (see [form settings](#form-settings)), `turnstile_secret` (see [Turnstile keys](#turnstile-keys)), `stripe_secret` (see [Stripe keys](#stripe-keys)) and `draft`. With `"draft": true` the form is created unpublished (status `draft`): it takes sandbox submissions only until you [publish it](#post-apiv1formsformpublish). Without it the form is published straight away. See [drafts and publishing](https://docs.getquickforms.com/drafts-and-publishing/). The sandbox key and webhook secret are generated for you.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Contact", "description": "General enquiries about our products", "schema": {"fields": [{"name": "email", "type": "email", "required": true}]}, "webhook_url": "https://example.com/hooks", "settings": {"notify": {"emails": ["you@yourdomain.com"]}}}'
```

### 201 response

```json
{
  "data": {
    "id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "key": null,
    "name": "Contact",
    "description": "General enquiries about our products",
    "mode": "live",
    "is_active": true,
    "status": "published",
    "published_at": "2026-11-06T10:00:00.000000Z",
    "draft": null,
    "endpoint_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
    "hosted_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
    "embed": {
      "script": "<div data-quickforms=\"01jc8xk2m5t9qw3e7r4y6u8v0p\"></div>\n<script src=\"https://YOUR-QUICKFORMS-HOST/embed.js\" defer></script>",
      "script_sri": "<div data-quickforms=\"01jc8xk2m5t9qw3e7r4y6u8v0p\"></div>\n<script src=\"https://YOUR-QUICKFORMS-HOST/embed/v1/embed.HASH.js\" integrity=\"sha384-...\" crossorigin=\"anonymous\" defer></script>",
      "tracker_sri": "<script src=\"https://YOUR-QUICKFORMS-HOST/embed/v1/quickforms-track.HASH.js\" integrity=\"sha384-...\" crossorigin=\"anonymous\" defer></script>",
      "iframe": "<iframe src=\"https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p/embed\" ...></iframe>"
    },
    "sandbox_key": "qf_sandbox_...",
    "schema": { "fields": [{ "name": "email", "type": "email", "label": "Email", "required": true }] },
    "settings": {
      "notify": { "enabled": true, "emails": ["you@yourdomain.com"], "subject": "New submission: {{_form_name}}", "reply_to_field": null },
      "routing": { "rules": [] },
      "autoresponder": { "enabled": false, "email_field": null, "subject": "Thanks, we got your message", "body": "..." },
      "redirect_url": null,
      "success_message": "Thanks! Your submission has been received.",
      "allowed_origins": ["*"],
      "honeypot_field": null,
      "honeypot_fields": [],
      "turnstile": { "enabled": false, "site_key": null },
      "altcha": { "enabled": false, "max_number": 100000 },
      "geo": { "mode": "off", "countries": [], "unknown": "allow" },
      "block_disposable_emails": false,
      "payment": { "enabled": false, "publishable_key": null, "currency": "gbp", "amount": null, "amount_field": null, "prices": [], "description": "{{_form_name}}" },
      "hosted": { "enabled": true, "layout": "classic", "...": "..." },
      "partials": { "enabled": false, "abandon_after_minutes": 30, "retention_days": 30 },
      "require_link": false,
      "confirmation": { "enabled": false, "...": "..." },
      "spam": { "enabled": true, "threshold": 0.8, "rules": [] }
    },
    "webhook_url": "https://example.com/hooks",
    "webhook_secret": "whsec_...",
    "turnstile_secret_set": false,
    "stripe_secret_set": false,
    "mail_provider_id": null,
    "submissions_count": 0,
    "created_at": "2026-11-06T10:00:00.000000Z",
    "updated_at": "2026-11-06T10:00:00.000000Z"
  }
}
```

### `GET /api/v1/form-templates`

The ready-made [templates](https://docs.getquickforms.com/creating-forms/#start-from-a-template). Each has a `key`, `name`, `summary`, `category`, `fields` (the field labels) and the full `draft` (name, description, schema and settings). Not paginated.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/form-templates \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": [
    {
      "key": "contact",
      "name": "Simple contact form",
      "summary": "Name, email and a message. The classic.",
      "category": "General",
      "fields": ["Name", "Email", "Message"],
      "draft": {
        "name": "Contact",
        "description": "General enquiries from visitors to our website.",
        "schema": {
          "fields": [
            { "name": "name", "type": "text", "label": "Name", "required": true, "max": 100 },
            { "name": "email", "type": "email", "label": "Email", "required": true },
            { "name": "message", "type": "textarea", "label": "Message", "required": true, "min": 10, "max": 5000 }
          ]
        },
        "settings": {
          "autoresponder": { "enabled": true, "subject": "Thanks {{name}}, we got your message" }
        }
      }
    },
    { "key": "service-business", "name": "Service business enquiry", "...": "..." }
  ]
}
```

Create a form from one with `POST /api/v1/forms` and `{"template": "contact"}`, plus any overrides. The example values below are illustrative.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"template": "contact", "name": "Website contact", "settings": {"notify": {"emails": ["you@yourdomain.com"]}}}'
```

### 422 response

```json
{
  "message": "The selected template is invalid.",
  "errors": { "template": ["The selected template is invalid."] }
}
```

A valid key responds `201` with the created form, the same as [`POST /api/v1/forms`](#post-apiv1forms). The `422` above is what an unknown key returns.

### `POST /api/v1/forms/generate`

Design a form with the [AI form builder](https://docs.getquickforms.com/creating-forms/#describe-it-to-the-ai-form-builder) from a plain-English `prompt` (5 to 2000 characters). With `save` false (the default) it returns a draft and saves nothing. With `save` true it creates the form and responds `201` with it, the same as [`POST /api/v1/forms`](#post-apiv1forms).

The draft has a name, a description, a schema and settings with a success message, an autoresponder and up to 3 [smart rules](https://docs.getquickforms.com/spam-filter/#smart-rules). It is in the shape `POST /api/v1/forms` accepts. Review it before you save it.

The prompt and draft below are illustrative. The model's answer varies.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/generate \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "A quote request form for a garden landscaping business", "save": false}'
```

### 200 response

```json
{
  "data": {
    "draft": {
      "name": "Landscaping quote",
      "description": "Homeowners asking for a quote for garden landscaping work.",
      "schema": {
        "fields": [
          { "name": "name", "type": "text", "label": "Name", "required": true },
          { "name": "email", "type": "email", "label": "Email", "required": true },
          { "name": "work", "type": "checkboxes", "label": "What work do you need?", "required": true, "options": ["Design", "Planting", "Paving", "Fencing"] },
          { "name": "details", "type": "textarea", "label": "Tell us about your garden", "required": false }
        ]
      },
      "settings": {
        "success_message": "Thanks, we will be in touch about your quote.",
        "autoresponder": { "enabled": true, "subject": "Thanks {{name}}, we got your quote request", "body": "..." },
        "spam": { "rules": ["Must be a request for garden landscaping work"] }
      }
    }
  }
}
```

### 422 response

```json
{
  "message": "The AI form builder is not configured. Set OPENAI_API_KEY to use it.",
  "errors": { "prompt": ["The AI form builder is not configured. Set OPENAI_API_KEY to use it."] }
}
```

Errors are a `422` on `prompt` when the prompt is too short or too long, when the builder is not configured, or when the model could not create a form, and a `429` when the team has used its generated forms for the hour.

### `GET /api/v1/forms/{form}`

One form with its schema, settings, endpoint URL, hosted page URL (`hosted_url`, the same address), embed snippets, sandbox key and webhook secret. The top-level attributes are the published version. `status` is `draft` (never published), `changes_pending` (live, with a saved draft) or `published`, `published_at` is when it was last published (`null` if never), and `draft` holds the unpublished changes or `null`. See [drafts and publishing](https://docs.getquickforms.com/drafts-and-publishing/#statuses).

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "key": null,
    "name": "Contact",
    "description": "General enquiries about our products",
    "mode": "live",
    "is_active": true,
    "status": "published",
    "published_at": "2026-11-06T10:00:00.000000Z",
    "draft": null,
    "endpoint_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
    "hosted_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
    "embed": {
      "script": "<div data-quickforms=\"01jc8xk2m5t9qw3e7r4y6u8v0p\"></div>\n<script src=\"https://YOUR-QUICKFORMS-HOST/embed.js\" defer></script>",
      "script_sri": "<div data-quickforms=\"01jc8xk2m5t9qw3e7r4y6u8v0p\"></div>\n<script src=\"https://YOUR-QUICKFORMS-HOST/embed/v1/embed.HASH.js\" integrity=\"sha384-...\" crossorigin=\"anonymous\" defer></script>",
      "tracker_sri": "<script src=\"https://YOUR-QUICKFORMS-HOST/embed/v1/quickforms-track.HASH.js\" integrity=\"sha384-...\" crossorigin=\"anonymous\" defer></script>",
      "iframe": "<iframe src=\"https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p/embed\" ...></iframe>"
    },
    "sandbox_key": "qf_sandbox_...",
    "schema": { "fields": [{ "name": "email", "type": "email", "label": "Email", "required": true }] },
    "settings": {
      "notify": { "enabled": true, "emails": ["you@yourdomain.com"], "subject": "New submission: {{_form_name}}", "reply_to_field": null },
      "routing": { "rules": [] },
      "autoresponder": { "enabled": false, "email_field": null, "subject": "Thanks, we got your message", "body": "..." },
      "redirect_url": null,
      "success_message": "Thanks! Your submission has been received.",
      "allowed_origins": ["*"],
      "honeypot_field": null,
      "honeypot_fields": [],
      "turnstile": { "enabled": false, "site_key": null },
      "altcha": { "enabled": false, "max_number": 100000 },
      "geo": { "mode": "off", "countries": [], "unknown": "allow" },
      "block_disposable_emails": false,
      "payment": { "enabled": false, "publishable_key": null, "currency": "gbp", "amount": null, "amount_field": null, "prices": [], "description": "{{_form_name}}" },
      "hosted": { "enabled": true, "layout": "classic", "...": "..." },
      "partials": { "enabled": false, "abandon_after_minutes": 30, "retention_days": 30 },
      "require_link": false,
      "confirmation": { "enabled": false, "...": "..." },
      "spam": { "enabled": true, "threshold": 0.8, "rules": [] }
    },
    "webhook_url": "https://example.com/hooks",
    "turnstile_secret_set": false,
    "stripe_secret_set": false,
    "mail_provider_id": null,
    "submissions_count": 0,
    "created_at": "2026-11-06T10:00:00.000000Z",
    "updated_at": "2026-11-06T10:00:00.000000Z"
  }
}
```

### `PATCH /api/v1/forms/{form}`

Update a form (`PUT` works too). Only the keys you send change. Settings are merged, but lists such as `notify.emails`, `allowed_origins`, `spam.rules`, `honeypot_fields`, `routing.rules`, `geo.countries` and `payment.prices` are replaced, and sending `schema` replaces the whole field list.

Pass `"draft": true` to keep the changes as a draft instead of making them live: the published version keeps taking live submissions until you [publish](#post-apiv1formsformpublish). Drafts build on each other, and a draft identical to the live version is dropped. `is_active`, `key`, `turnstile_secret` and `stripe_secret` always apply immediately. Without `draft`, changes go live straight away, together with any pending draft. See [drafts and publishing](https://docs.getquickforms.com/drafts-and-publishing/).

### Request

```bash
curl -X PATCH https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"mode": "sandbox", "settings": {"allowed_origins": ["https://yoursite.com"]}}'
```

### 200 response

```json
{
  "data": {
    "id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "key": null,
    "name": "Contact",
    "description": "General enquiries about our products",
    "mode": "live",
    "is_active": true,
    "status": "published",
    "published_at": "2026-11-06T10:00:00.000000Z",
    "draft": null,
    "endpoint_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
    "hosted_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
    "embed": {
      "script": "<div data-quickforms=\"01jc8xk2m5t9qw3e7r4y6u8v0p\"></div>\n<script src=\"https://YOUR-QUICKFORMS-HOST/embed.js\" defer></script>",
      "script_sri": "<div data-quickforms=\"01jc8xk2m5t9qw3e7r4y6u8v0p\"></div>\n<script src=\"https://YOUR-QUICKFORMS-HOST/embed/v1/embed.HASH.js\" integrity=\"sha384-...\" crossorigin=\"anonymous\" defer></script>",
      "tracker_sri": "<script src=\"https://YOUR-QUICKFORMS-HOST/embed/v1/quickforms-track.HASH.js\" integrity=\"sha384-...\" crossorigin=\"anonymous\" defer></script>",
      "iframe": "<iframe src=\"https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p/embed\" ...></iframe>"
    },
    "sandbox_key": "qf_sandbox_...",
    "schema": { "fields": [{ "name": "email", "type": "email", "label": "Email", "required": true }] },
    "settings": {
      "notify": { "enabled": true, "emails": ["you@yourdomain.com"], "subject": "New submission: {{_form_name}}", "reply_to_field": null },
      "routing": { "rules": [] },
      "autoresponder": { "enabled": false, "email_field": null, "subject": "Thanks, we got your message", "body": "..." },
      "redirect_url": null,
      "success_message": "Thanks! Your submission has been received.",
      "allowed_origins": ["*"],
      "honeypot_field": null,
      "honeypot_fields": [],
      "turnstile": { "enabled": false, "site_key": null },
      "altcha": { "enabled": false, "max_number": 100000 },
      "geo": { "mode": "off", "countries": [], "unknown": "allow" },
      "block_disposable_emails": false,
      "payment": { "enabled": false, "publishable_key": null, "currency": "gbp", "amount": null, "amount_field": null, "prices": [], "description": "{{_form_name}}" },
      "hosted": { "enabled": true, "layout": "classic", "...": "..." },
      "partials": { "enabled": false, "abandon_after_minutes": 30, "retention_days": 30 },
      "require_link": false,
      "confirmation": { "enabled": false, "...": "..." },
      "spam": { "enabled": true, "threshold": 0.8, "rules": [] }
    },
    "webhook_url": "https://example.com/hooks",
    "turnstile_secret_set": false,
    "stripe_secret_set": false,
    "mail_provider_id": null,
    "submissions_count": 0,
    "created_at": "2026-11-06T10:00:00.000000Z",
    "updated_at": "2026-11-06T10:00:00.000000Z"
  }
}
```

### `POST /api/v1/forms/{form}/publish`

Make the saved draft live, or put a form that has never been published live. Clears `draft` and sets `published_at`. Returns the form.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/publish \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "name": "Contact",
    "status": "published",
    "published_at": "2026-11-06T10:30:00.000000Z",
    "draft": null,
    "...": "..."
  }
}
```

### `DELETE /api/v1/forms/{form}/draft`

Throw away the saved draft. The live version is untouched. Returns the form.

### Request

```bash
curl -X DELETE https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/draft \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "name": "Contact",
    "status": "published",
    "draft": null,
    "...": "..."
  }
}
```

The response values above are illustrative.

### `POST /api/v1/forms/{form}/webhook-secret/rotate`

Replace the form's webhook signing secret. Returns the form with the new `webhook_secret`. The old secret stops working straight away, so update your receiver.

`webhook_secret` is returned by this endpoint and when a form is created. Other form responses leave it out.

`sandbox_key` and `webhook_secret` are only included for an API key with the `write` ability whose user can manage forms. Read-only access leaves them out.

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/webhook-secret/rotate \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### `POST /api/v1/forms/{form}/sandbox-key/rotate`

Replace the form's sandbox key. Returns the form with the new `sandbox_key`. The old key stops working straight away.

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/sandbox-key/rotate \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### `DELETE /api/v1/forms/{form}`

Delete a form with no live submissions. Responds `204` with no body. Sandbox submissions don't count, so a form you have only tested can be deleted. A form with live submissions responds `422` on `form`: [archive](#post-apiv1formsformarchive) it instead. Every form response has `can_delete`.

### Request

```bash
curl -X DELETE https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### `POST /api/v1/forms/{form}/archive`

Stop a form taking responses and keep its submissions. Sets `archived_at` and returns the form. Its hosted page shows the closed message, and submissions respond `410` with the code `form_closed`. Archived forms are still returned by `GET /api/v1/forms`.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/archive \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### `POST /api/v1/forms/{form}/restore`

Bring an archived form back. Clears `archived_at` and returns the form.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/restore \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

## Forms as code

Address a form by its `key` instead of its ID. This is what the [CLI, GitHub Action and Netlify plugin](https://docs.getquickforms.com/forms-as-code/) use.

### `PUT /api/v1/forms/by-key/{key}`

Create or update the form with this key from a definition: the attributes of [`POST /api/v1/forms`](#post-apiv1forms), plus `destinations` (matched to the form's destinations by `name`) and `prune_destinations` (`true` deletes destinations not in the list). Add `?dry_run=1` to validate everything without saving. Responds `201` when it creates the form, otherwise `200`.

### Request

```bash
curl -X PUT https://YOUR-QUICKFORMS-HOST/api/v1/forms/by-key/contact \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Contact", "schema": {"fields": [{"name": "email", "type": "email", "required": true}]}, "destinations": [{"name": "Team Slack", "type": "slack", "config": {"webhook_url": "https://hooks.slack.com/services/..."}}]}'
```

### 201 response

```json
{
  "data": {
    "id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "key": "contact",
    "name": "Contact",
    "status": "published",
    "...": "...",
    "destinations": [{ "id": "01jcd0...", "type": "slack", "name": "Team Slack", "...": "..." }]
  },
  "meta": {
    "dry_run": false,
    "created": true,
    "status": "published",
    "destinations": { "created": ["Team Slack"], "updated": [], "deleted": [] }
  }
}
```

Keys are 1 to 64 lowercase letters, numbers, dashes and underscores, starting with a letter or number. Anything else is a `422` on `key`. Destination errors carry their position, such as `destinations.0.config.access_token`. See [forms as code](https://docs.getquickforms.com/forms-as-code/).

### `GET /api/v1/forms/by-key/{key}`

The form with this key, with a `destinations` list. `404` if there is none.

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/forms/by-key/contact \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

## Form settings

| Key | Default | Description |
| --- | --- | --- |
| `notify.enabled` | `true` | Send the notification email. |
| `notify.emails` | `[]` | Up to 10 recipients. Empty means the team owner. |
| `notify.subject` | `"New submission: {{_form_name}}"` | Up to 200 characters. Supports `{{field}}`, `{{_form_name}}` and `{{_submission_id}}`. |
| `notify.reply_to_field` | `null` | The email field replies go to, or `"none"` for no reply-to. `null` picks the autoresponder's email field or the first email field. Must be one of the form's email fields. |
| `routing.rules` | `[]` | Up to 20 [routing rules](https://docs.getquickforms.com/routing-rules/). Each has `name`, `conditions` (up to 10 `{field, operator, value}`, all must match), `notify_emails` (up to 10), `notify_mode` (`add` or `replace`, default `add`) and `redirect_url`. `operator` is `equals`, `not_equals`, `contains`, `is_set` or `is_empty`. |
| `autoresponder.enabled` | `false` | Send the autoresponder. |
| `autoresponder.email_field` | `null` | Field holding the recipient. Defaults to the first email field. |
| `autoresponder.subject` | `"Thanks, we got your message"` | Supports `{{field}}`. |
| `autoresponder.body` | A short thank-you | Plain text, up to 5000 characters. Supports `{{field}}`. |
| `redirect_url` | `null` | Where HTML posts go after success. |
| `success_message` | `"Thanks! Your submission has been received."` | Up to 500 characters. Shown on the thank-you page, in the embed and in JSON responses. Placeholders like `{{name}}` are filled from the submission. |
| `allowed_origins` | `["*"]` | Up to 20 origins: `*`, `https://example.com` or `https://*.example.com`. Browser-only, see [CORS](https://docs.getquickforms.com/submit/#cors). |
| `honeypot_field` | `null` | Overrides the main honeypot name (`_gotcha`). |
| `honeypot_fields` | `[]` | Up to 5 extra [honeypots](https://docs.getquickforms.com/spam-protection/#honeypots), such as `website` or `fax`. |
| `turnstile.enabled` | `false` | Require a valid [Cloudflare Turnstile](https://docs.getquickforms.com/spam-protection/#cloudflare-turnstile) token on live submissions. Needs `site_key` and `turnstile_secret`. |
| `turnstile.site_key` | `null` | Your Turnstile widget's site key, up to 100 characters. |
| `altcha.enabled` | `false` | Require an [Altcha](https://docs.getquickforms.com/spam-protection/#altcha) proof-of-work solution on submissions. |
| `altcha.max_number` | `100000` | The upper bound of the Altcha challenge, 1000 to 1000000. |
| `geo.mode` | `off` | [Geo filters](https://docs.getquickforms.com/spam-protection/#geo-filters): `off`, `allow` (only the listed countries) or `block` (all but the listed countries). Filtered submissions are held as spam. |
| `geo.countries` | `[]` | Up to 250 two-letter country codes. |
| `geo.unknown` | `allow` | `allow` or `block` submissions whose country is unknown. |
| `block_disposable_emails` | `false` | Hold submissions with a [disposable email address](https://docs.getquickforms.com/spam-protection/#disposable-email-blocking) as spam. |
| `payment.enabled` | `false` | Take a Stripe [payment](https://docs.getquickforms.com/payments/) before the submission is accepted. Needs `publishable_key`, `stripe_secret`, and `amount` or `amount_field`. |
| `payment.publishable_key` | `null` | Your Stripe publishable key, starting `pk_`. |
| `payment.currency` | `gbp` | A three-letter currency code. |
| `payment.amount` | `null` | A fixed amount in the smallest currency unit (pence, cents), 1 to 99999999. |
| `payment.amount_field` | `null` | A field whose answer sets the price, with `prices`. |
| `payment.prices` | `[]` | A map of the field's options to amounts in the smallest unit. Replaced whole. |
| `payment.description` | `"{{_form_name}}"` | Up to 300 characters. |
| `webhook.events` | `null` | The [webhook events](https://docs.getquickforms.com/webhooks/#events) to send. `null` is `submission.created` only. Replaced whole, and an empty list sends nothing. |
| `partials.enabled` | `false` | Save progress from the embed and hosted page while people type. See [partial submissions](https://docs.getquickforms.com/partial-submissions/). |
| `partials.abandon_after_minutes` | `30` | Minutes without activity before a partial submission counts as abandoned, 5 to 1440. |
| `partials.retention_days` | `30` | Days partial submissions are kept, 1 to 90. |
| `confirmation.enabled` | `false` | Turn on [double opt-in](https://docs.getquickforms.com/double-opt-in/): send a confirmation email and hold everything else until it is confirmed. Needs an email field. |
| `confirmation.email_field` | `null` | The email field to confirm. Defaults to the first email field. |
| `confirmation.subject` | `"Please confirm your email address"` | Up to 200 characters. Supports `{{field}}`. |
| `confirmation.body` | A short request to confirm | Plain text, up to 5000 characters. Supports `{{field}}`. |
| `confirmation.button_label` | `"Confirm my email"` | Up to 60 characters. |
| `confirmation.expires_hours` | `48` | How long the confirmation link works, 1 to 720. |
| `confirmation.on_expiry` | `keep` | `keep` marks unconfirmed submissions `expired`, `delete` deletes them. |
| `confirmation.redirect_url` | `null` | Where people go after confirming. |
| `confirmation.confirmed_message` | `"Thanks, your email address is confirmed."` | Shown on the hosted confirmed page when there is no redirect. Up to 500 characters. Supports `{{field}}`. |
| `require_link` | `false` | Only accept live submissions that come through a [personal link](https://docs.getquickforms.com/personal-links/). Sandbox submissions don't need one. |
| `hosted.enabled` | `true` | Serve the [hosted form page](https://docs.getquickforms.com/hosted-forms/) at `GET /f/{form}`. |
| `hosted.layout` | `classic` | `classic`, `steps` or `compact`. |
| `hosted.appearance` | `auto` | `auto`, `light` or `dark`. |
| `hosted.accent` | `#0b0b0f` | A six-digit hex colour. |
| `hosted.font` | `sans` | `sans`, `serif` or `mono`. |
| `hosted.radius` | `rounded` | `sharp`, `rounded` or `pill`. |
| `hosted.background` | `plain` | `plain`, `gradient` or `grid`. |
| `hosted.title` | `null` | Up to 120 characters. Defaults to the form name. |
| `hosted.intro` | `null` | Up to 1000 characters. |
| `hosted.submit_label` | `"Submit"` | Up to 40 characters. |
| `hosted.logo_url` | `null` | An `https` URL. |
| `hosted.favicon_url` | `null` | An `https` URL. |
| `hosted.show_branding` | `true` | Show "Powered by quickforms" under the form. |
| `spam.enabled` | `true` | Run the [spam filter](https://docs.getquickforms.com/spam-filter/) on this form's submissions. |
| `spam.threshold` | `0.8` | Between `0.5` and `0.99`. Submissions at or above it are flagged. |
| `spam.rules` | `[]` | Up to 10 [smart rules](https://docs.getquickforms.com/spam-filter/#smart-rules), each up to 300 characters. |

### Honeypot names

`honeypot_field` and each entry in `honeypot_fields` can be up to 64 letters, numbers, dashes and underscores, optionally starting with one underscore. Entries in `honeypot_fields` must be distinct. A honeypot can't share a name with one of the form's fields: that is a `422` on `settings.honeypot_fields`.

```json title="422 response"
{
  "message": "Honeypots can't share a name with a field: website.",
  "errors": { "settings.honeypot_fields": ["Honeypots can't share a name with a field: website."] }
}
```

### Turnstile keys

`turnstile_secret` is a top-level attribute, not part of `settings`. It is your Turnstile widget's secret key, up to 200 characters. It is write-only: it is encrypted at rest and never returned. Forms return `turnstile_secret_set` instead, `true` once a secret is saved. Sending a blank `turnstile_secret` keeps the saved one. The secret is not part of a [draft](https://docs.getquickforms.com/drafts-and-publishing/), so it is saved straight away even with `"draft": true`.

Switching `settings.turnstile.enabled` on without both a site key and a secret (sent now or saved before) is a `422` on `settings.turnstile.site_key`:

```json title="422 response"
{
  "message": "Add your Turnstile site key and secret key to switch Turnstile on.",
  "errors": { "settings.turnstile.site_key": ["Add your Turnstile site key and secret key to switch Turnstile on."] }
}
```

See [spam protection](https://docs.getquickforms.com/spam-protection/#cloudflare-turnstile) for setting up the widget.

### Stripe keys

`stripe_secret` is a top-level attribute, not part of `settings`: your Stripe secret key, `sk_test_`, `sk_live_`, `rk_test_` or `rk_live_`, up to 200 characters. Like `turnstile_secret` it is write-only, encrypted at rest and never returned. Forms return `stripe_secret_set` instead, and a blank value keeps the saved one.

Switching `settings.payment.enabled` on without a publishable key and a secret is a `422` on `settings.payment.publishable_key`, and without `amount` or `amount_field` a `422` on `settings.payment.amount`. See [payments](https://docs.getquickforms.com/payments/).

### Routing and reply-to

Each routing rule's condition `field`, and `notify.reply_to_field`, must name one of the form's fields (for reply-to, an email field, or `"none"`). Otherwise it is a `422`, for example on `settings.routing.rules.0.conditions.0.field`. See [routing rules](https://docs.getquickforms.com/routing-rules/).

### Double opt-in

Switching `settings.confirmation.enabled` on needs an email field, and `confirmation.email_field` must name one of the form's email fields. Otherwise it is a `422` on `settings.confirmation.email_field`:

```json title="422 response"
{
  "message": "Double opt-in needs an email field to send the confirmation to.",
  "errors": { "settings.confirmation.email_field": ["Double opt-in needs an email field to send the confirmation to."] }
}
```

See [double opt-in](https://docs.getquickforms.com/double-opt-in/).

## Personal links

[Personal links](https://docs.getquickforms.com/personal-links/) are unique URLs to a form's hosted page, one per person.

### `POST /api/v1/forms/{form}/links`

Create one link, or up to 500 with `{"links": [...]}`. Each link takes `label`, `email`, `prefill`, `metadata`, `max_submissions` (default `1`, `null` for unlimited) and `expires_at`, all optional. The response is always a list. See [link fields](https://docs.getquickforms.com/personal-links/#link-fields).

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/links \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"label": "Ada Lovelace", "email": "ada@example.com", "metadata": {"customer_id": "cus_123"}}'
```

### 201 response

```json
{
  "data": [
    {
      "id": "01jcb2m4k8q6r7s9t0v1w2x3y4",
      "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
      "token": "lnk_Xq3...",
      "url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p?link=lnk_Xq3...",
      "status": "open",
      "label": "Ada Lovelace",
      "email": "ada@example.com",
      "prefill": null,
      "metadata": { "customer_id": "cus_123" },
      "max_submissions": 1,
      "submissions_count": 0,
      "last_submitted_at": null,
      "expires_at": null,
      "revoked_at": null,
      "created_at": "2026-11-06T10:00:00.000000Z"
    }
  ]
}
```

`prefill` keys must be fields of the form, otherwise it is a `422` on `prefill` (or `links.N.prefill` in a bulk call).

### `GET /api/v1/forms/{form}/links`

A form's links, newest first. Filter with `status` (`open`, `completed`, `expired` or `revoked`) and `email`. Paginated with `per_page` (1 to 200, default 50) and `page`.

### `GET /api/v1/links/{link}`

One link.

### `DELETE /api/v1/links/{link}`

Revoke a link. It stops working straight away and its submissions are kept. Returns the revoked link.

## Submissions

### `POST /api/v1/forms/{form}/test-submissions`

Send a sandbox test submission with `{"data": {...}}`. It is validated and stored, and every delivery is simulated with its full payload. Like every sandbox submission it runs against the form's [draft](https://docs.getquickforms.com/drafts-and-publishing/#test-a-draft), if it has one. Validation errors come back as a 422.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/test-submissions \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"data": {"name": "Ada Lovelace", "email": "ada@example.com"}}'
```

### 201 response

```json
{
  "data": {
    "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "sandbox": true,
    "source": "api",
    "deliveries": [{ "channel": "webhook", "status": "simulated", "payload": { "...": "..." } }],
    "...": "..."
  }
}
```

### `GET /api/v1/forms/{form}/submissions`

A form's submissions, newest first. Filters:

| Query | Description |
| --- | --- |
| `sandbox` | `true` or `false`. Omit for both. |
| `spam` | `true` for submissions that failed the base spam check, `false` for ones that passed. Omit for both. |
| `failed_rules` | `true` for submissions that broke the form's [smart rules](https://docs.getquickforms.com/spam-filter/#smart-rules), `false` for ones that followed them. Omit for both. |
| `confirmation` | `pending`, `confirmed` or `expired`, for forms with [double opt-in](https://docs.getquickforms.com/double-opt-in/). Omit for all. |
| `delivery` | Submissions with a [delivery](#deliveries) that is `failed`, `retrying`, or `issue` for either. Omit for all. |
| `link_id` | Only submissions made through this [personal link](https://docs.getquickforms.com/personal-links/). |
| `search` | Free text matched anywhere in the submission data. |
| `since` | ISO 8601 date or datetime. Submissions created at or after it. |
| `until` | ISO 8601 date or datetime. Submissions created at or before it. |
| `fields[name]=value` | Exact match on a field value. Repeat for more fields. Unknown field names are ignored. |
| `per_page` | 1 to 100, default 25. |
| `page` | Page number. |

### Request

```bash
curl -G https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/submissions \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  --data-urlencode "sandbox=false" \
  --data-urlencode "since=2026-11-01" \
  --data-urlencode "fields[plan]=pro"
```

### 200 response

```json
{
  "data": [
    {
      "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
      "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
      "link_id": null,
      "sandbox": false,
      "spam": false,
      "failed_rules": false,
      "held": false,
      "classification": { "spam": false, "rules_broken": false, "checks": { "spam": "passed", "rules": "none" }, "spam_probability": 0.0213, "threshold": 0.8, "rules": [], "reasons": [], "model": "jev-latest", "error": null, "duration_ms": 388 },
      "reviewed_at": null,
      "confirmation_status": null,
      "confirmed_at": null,
      "source": "endpoint",
      "data": { "name": "Ada Lovelace", "email": "ada@example.com", "plan": "pro" },
      "meta": { "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 ...", "referer": "https://yoursite.com/contact", "origin": "https://yoursite.com" },
      "deliveries": [{ "id": "01jca9qf4b...", "channel": "webhook", "status": "succeeded" }],
      "created_at": "2026-11-06T10:24:00.000000Z"
    }
  ],
  "links": { "first": "...?page=1", "last": "...?page=3", "prev": null, "next": "...?page=2" },
  "meta": { "current_page": 1, "from": 1, "last_page": 3, "per_page": 25, "to": 25, "total": 61 }
}
```

### `GET /api/v1/forms/{form}/export`

Download a form's submissions as a file, oldest first. It is streamed, so large exports work. Query parameters:

| Query | Description |
| --- | --- |
| `format` | `csv` (default), `json` or `ndjson`. |
| `sandbox`, `spam`, `failed_rules` | `true` or `false`. Omit for both. |
| `confirmation` | `pending`, `confirmed` or `expired`. |
| `delivery` | `failed`, `retrying`, or `issue` for either. |
| `link_id` | Only submissions made through this personal link. |
| `since`, `until` | Dates or datetimes. |

```bash
curl "https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/export?format=csv&sandbox=false" \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -o submissions.csv
```

CSV has the columns `id`, `submitted_at`, `sandbox`, `spam`, `failed_rules`, `confirmation` and `link` (the link's label), then one per field, headed by its label. Cells starting with `=`, `+`, `-` or `@` get a leading `'` so spreadsheets don't run them as formulas. JSON is one array and NDJSON one object per line, each with `id`, `submitted_at`, `sandbox`, `spam`, `failed_rules`, `confirmation_status`, `link_id`, `data` and `tracking`. File fields become signed download links that work for 30 days. See [exports](https://docs.getquickforms.com/exports/).

### `GET /api/v1/submissions/{submission}`

One submission with its data, metadata (IP, user agent, referer, origin), spam verdict and every delivery including each attempt. `spam` says whether it failed the base spam check, `failed_rules` whether it broke the form's smart rules, and `held` whether its emails and webhooks are being held because of either flag. `classification` holds the [spam filter's result](https://docs.getquickforms.com/spam-filter/#the-classification) (`null` when the filter did not run) and `reviewed_at` is when someone last marked it as spam or not spam. `link_id` is the [personal link](https://docs.getquickforms.com/personal-links/) it came through, with the link's label, email and metadata in `meta.link`, otherwise `null`. `confirmation_status` is `null` when the form doesn't use [double opt-in](https://docs.getquickforms.com/double-opt-in/) (or there was no address to confirm), otherwise `pending`, `confirmed` or `expired`, and `confirmed_at` is when it was confirmed.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "link_id": null,
    "sandbox": false,
    "spam": false,
    "failed_rules": false,
    "held": false,
    "classification": { "spam": false, "rules_broken": false, "checks": { "spam": "passed", "rules": "none" }, "spam_probability": 0.0213, "threshold": 0.8, "rules": [], "reasons": [], "model": "jev-latest", "error": null, "duration_ms": 388 },
    "reviewed_at": null,
    "confirmation_status": null,
    "confirmed_at": null,
    "source": "endpoint",
    "data": { "name": "Ada Lovelace", "email": "ada@example.com", "plan": "pro" },
    "meta": { "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 ...", "referer": "https://yoursite.com/contact", "origin": "https://yoursite.com" },
    "deliveries": [
      { "id": "01jca9qf4bx2c3d4e5f6g7h8j9", "channel": "webhook", "status": "succeeded", "attempts_count": 2, "attempts": ["..."], "...": "..." }
    ],
    "created_at": "2026-11-06T10:24:00.000000Z"
  }
}
```

### `POST /api/v1/submissions/{submission}/review`

Mark a submission as spam or not spam with `{"spam": true}` or `{"spam": false}`. Returns the submission.

- Marking a flagged submission as not spam clears both `spam` and `failed_rules` and releases it: its emails and webhooks are created and delivered. Sandbox submissions are simulated as usual.
- Marking a delivered submission as spam only labels it. Anything already delivered stays delivered.
- On a form with [double opt-in](https://docs.getquickforms.com/double-opt-in/), marking a flagged submission as not spam sends the confirmation email first. The rest is held until the person confirms.

See [flagged submissions](https://docs.getquickforms.com/spam-filter/#flagged-submissions).

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID/review \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"spam": false}'
```

### 200 response

```json
{
  "data": {
    "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "link_id": null,
    "sandbox": false,
    "spam": false,
    "failed_rules": false,
    "held": false,
    "classification": {
      "spam": false,
      "rules_broken": true,
      "checks": { "spam": "passed", "rules": "failed" },
      "spam_probability": 0.3127,
      "threshold": 0.8,
      "rules": [{ "rule": "Must be about bike fitting", "probability": 0.9412, "broken": true }],
      "reasons": ["Breaks the rule \"Must be about bike fitting\""],
      "model": "jev-latest",
      "error": null,
      "duration_ms": 412
    },
    "reviewed_at": "2026-11-06T11:02:00.000000Z",
    "confirmation_status": null,
    "confirmed_at": null,
    "source": "endpoint",
    "data": { "name": "Ada Lovelace", "email": "ada@example.com", "message": "..." },
    "meta": { "...": "..." },
    "deliveries": [
      { "id": "01jcab2r7k...", "channel": "notification", "status": "pending", "...": "..." },
      { "id": "01jcab2r7m...", "channel": "webhook", "status": "pending", "...": "..." }
    ],
    "created_at": "2026-11-06T10:24:00.000000Z"
  }
}
```

`classification` keeps the filter's original verdict. `spam` and `failed_rules` are the current labels.

### `POST /api/v1/submissions/{submission}/confirm`

[Double opt-in](https://docs.getquickforms.com/double-opt-in/): confirm a pending submission on the person's behalf, for example when they confirmed another way. It becomes `confirmed`, `confirmed_at` is set and its held emails and webhooks are created and delivered. Returns the submission. Confirming a confirmed submission changes nothing.

It is a `422` on `submission` with "This submission expired before it was confirmed." for an expired submission (resend the confirmation email instead), or "This submission is not waiting for confirmation." for a submission that doesn't use double opt-in.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID/confirm \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "confirmation_status": "confirmed",
    "confirmed_at": "2026-11-06T11:15:00.000000Z",
    "deliveries": [
      { "id": "01jca9qf4b...", "channel": "confirmation", "status": "succeeded", "...": "..." },
      { "id": "01jcac5t2n...", "channel": "notification", "status": "pending", "...": "..." },
      { "id": "01jcac5t2p...", "channel": "webhook", "status": "pending", "...": "..." }
    ],
    "...": "..."
  }
}
```

### `POST /api/v1/submissions/{submission}/resend-confirmation`

[Double opt-in](https://docs.getquickforms.com/double-opt-in/): send a new confirmation email with a fresh link. Works on `pending` and `expired` submissions, and sets an expired one back to `pending`, so the expiry clock starts again from the new email. Returns the submission.

It is a `422` on `submission` with "Only unconfirmed submissions can be sent a confirmation email." for a confirmed submission or one that doesn't use double opt-in, or "This submission has no valid email address to confirm." when the email field has no valid address.

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID/resend-confirmation \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

## Public endpoints

These are not part of `/api/v1`. They need no API key, because browsers and other people's websites call them. Every `POST` route also answers `OPTIONS` preflight requests.

| Method and path | What it does | Details |
| --- | --- | --- |
| `POST /f/{form}` | Submit the form. | [Submit endpoint](https://docs.getquickforms.com/submit/) |
| `GET /f/{form}` | The hosted form page. | [Hosted forms](https://docs.getquickforms.com/hosted-forms/) |
| `GET /f/{form}/embed` | The form page for an `<iframe>`. | [Embed](https://docs.getquickforms.com/embed/) |
| `GET /f/{form}/schema.json` | The published schema the embed widget renders. | [Embed](https://docs.getquickforms.com/embed/) |
| `POST /f/{form}/uploads` | Get a pre-signed URL to upload a file directly. | [Direct uploads](#post-fformuploads) |
| `POST /f/{form}/payment` | Check the answers and create a Stripe PaymentIntent, before submitting. | [Payments](https://docs.getquickforms.com/payments/) |
| `POST /f/{form}/progress` | Save progress while someone fills the form in. | [Partial submissions](https://docs.getquickforms.com/partial-submissions/) |
| `POST /f/{form}/view` | Count an anonymous view. | [Analytics](https://docs.getquickforms.com/analytics/) |
| `GET /f/{form}/altcha` | A fresh Altcha challenge. | [Altcha](https://docs.getquickforms.com/spam-protection/#altcha) |

### `POST /f/{form}/uploads`

Called from the browser or server that submits the form. Send `{"field", "filename", "size", "content_type"}` for a file field and get back an upload ID and a pre-signed URL to `PUT` the file to. Then submit the form as JSON with the `upl_...` ID as the file field's value.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/f/FORM_ID/uploads \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"field": "cv", "filename": "cv.pdf", "size": 204800, "content_type": "application/pdf"}'
```

### 201 response

```json
{
  "upload_id": "upl_01jcb4m7x2k9p3q8r5t6v0w1yz",
  "url": "https://YOUR-BUCKET-ENDPOINT/submissions/01jc8xk2m5t9qw3e7r4y6u8v0p/01jcb4m7x2k9p3q8r5t6v0w1yz.pdf?X-Amz-Signature=...",
  "method": "PUT",
  "headers": { "Content-Type": "application/pdf" },
  "expires_at": "2026-11-06T10:39:00+00:00"
}
```

The URL works for 15 minutes and the upload ID must be used within 24 hours. Unpublished forms need the `X-Quickforms-Sandbox` header, and requests are limited to 60 a minute per IP address. See [file uploads](https://docs.getquickforms.com/file-uploads/) for the full flow, limits, errors and storage setup.

## Deliveries

### `GET /api/v1/submissions/{submission}/deliveries`

Every delivery for a submission (notification, autoresponder, webhook) with its attempts.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID/deliveries \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": [
    { "id": "01jca9qf4b...", "channel": "notification", "status": "succeeded", "attempts": ["..."], "...": "..." },
    { "id": "01jca9qf4c...", "channel": "webhook", "status": "succeeded", "attempts": ["..."], "...": "..." }
  ]
}
```

### `GET /api/v1/deliveries/{delivery}`

One delivery with its payload and the full attempt log.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/deliveries/DELIVERY_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jca9qf4bx2c3d4e5f6g7h8j9",
    "submission_id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "channel": "webhook",
    "status": "succeeded",
    "target": "https://example.com/hooks",
    "attempts_count": 2,
    "next_retry_at": null,
    "delivered_at": "2026-11-06T10:24:12.000000Z",
    "last_error": null,
    "payload": { "method": "POST", "headers": { "...": "..." }, "body": "..." },
    "attempts": [
      { "number": 1, "succeeded": false, "status_code": 503, "response_excerpt": "Service Unavailable", "error": "Endpoint responded with HTTP 503", "duration_ms": 1840, "next_retry_at": "2026-11-06T10:24:12.000000Z", "created_at": "2026-11-06T10:24:02.000000Z" },
      { "number": 2, "succeeded": true, "status_code": 200, "response_excerpt": "{\"received\":true}", "error": null, "duration_ms": 96, "next_retry_at": null, "created_at": "2026-11-06T10:24:12.000000Z" }
    ],
    "created_at": "2026-11-06T10:24:00.000000Z"
  }
}
```

### `POST /api/v1/deliveries/{delivery}/redeliver`

Queue a fresh attempt for a live delivery, whatever its state. Responds `202`. Sandbox deliveries, and deliveries whose payload has been cleared by retention, return a 422.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/deliveries/DELIVERY_ID/redeliver \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 202 response

```json
{
  "data": { "id": "01jca9qf4bx2c3d4e5f6g7h8j9", "status": "pending", "next_retry_at": null, "...": "..." }
}
```

## Destinations

[Destinations](https://docs.getquickforms.com/destinations/) send submissions to Slack, Discord, HubSpot, Attio, Customer.io, Google Sheets or any HTTP endpoint, alongside email and the form webhook. They are delivered, retried and simulated like webhooks, and show up as deliveries with channel `destination`.

### `GET /api/v1/destination-types`

The destination types: `slack`, `discord`, `http`, `hubspot`, `attio`, `customerio` and `google_sheets`. Each has a `type`, `label`, `description`, `config_fields` (each with its `key`, `label`, `type`, `required` and, where it has them, `options`, `default` and `help`), `mapping_targets` and `custom_mapping`. Not paginated.

### `GET /api/v1/forms/{form}/destinations`

A form's destinations, oldest first. Not paginated.

### `POST /api/v1/forms/{form}/destinations`

Add a destination. `type`, `name` (up to 80 characters) and `config` are required. Optional: `enabled` (default `true`), `mapping` (up to 100 target keys, each a template with `{{field}}` placeholders; leave it out for the type's default mapping) and `conditions` (up to 10 `{field, operator, value}`, all must match, with `operator` one of `equals`, `not_equals`, `contains`, `is_set` or `is_empty`). A form can have up to 20 destinations. Responds `201`.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/destinations \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"type": "slack", "name": "Sales only", "config": {"webhook_url": "https://hooks.slack.com/services/..."}, "conditions": [{"field": "topic", "operator": "equals", "value": "sales"}]}'
```

### 201 response

```json
{
  "data": {
    "id": "01jcd0m2x4...",
    "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "type": "slack",
    "service": "Slack",
    "name": "Sales only",
    "enabled": true,
    "config": { "webhook_url": "••••••••••••••••••••••••••••••••••••••••••..." },
    "mapping": { "...": "..." },
    "conditions": [{ "field": "topic", "operator": "equals", "value": "sales" }],
    "created_at": "2026-11-06T10:00:00.000000Z",
    "updated_at": "2026-11-06T10:00:00.000000Z"
  }
}
```

Secret config values are write-only and come back masked, showing only their last 4 characters. Validation errors are a `422` on the key, such as `config.access_token`, `mapping.email` (a required mapping target left empty), `conditions.0.field` (not one of the form's fields) or `type`.

### `GET /api/v1/destinations/{destination}`

One destination.

### `PATCH /api/v1/destinations/{destination}`

Update a destination. Only the keys you send change, and `type` can't be changed. A blank or missing secret keeps the saved one. Returns the destination.

### `DELETE /api/v1/destinations/{destination}`

Delete a destination. Responds `204`.

### `POST /api/v1/destinations/{destination}/preview`

A dry run: exactly what the destination would send, with secrets redacted. Nothing is sent. Send `data` with sample field values, or `submission_id` for one of the form's submissions. With neither it uses the latest submission, or made-up values when there are none.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/destinations/DESTINATION_ID/preview \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Grace", "email": "grace@example.com", "message": "Hi"}}'
```

### 200 response

```json
{
  "data": {
    "would_send": true,
    "requests": [{ "method": "POST", "url": "https://api.hubapi.com/...", "headers": { "Authorization": "[redacted]", "...": "..." }, "body": { "...": "..." } }],
    "mapped": { "email": "grace@example.com", "firstname": "Grace" },
    "submission_id": null
  }
}
```

`would_send` is `false` when the destination's conditions don't match. `submission_id` is the stored submission used, or `null` for sample data.

## Automation hooks

REST hooks for [Zapier, Make and n8n](https://docs.getquickforms.com/zapier-and-n8n/). A subscription is an `http` destination that sends the `submission.created` event, so it is retried and logged like any other destination.

### `POST /api/v1/hooks`

Subscribe a URL to a form. `form_id` and `target_url` (an `https` URL, up to 2048 characters) are required. `name` is optional, up to 80 characters, and defaults to "Automation hook". Responds `201` with the destination it created.

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/hooks \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"form_id": "FORM_ID", "target_url": "https://hooks.zapier.com/hooks/standard/...", "name": "Zapier"}'
```

### `DELETE /api/v1/hooks/{hook}`

Unsubscribe: delete the `http` destination with this ID. Responds `204`.

### `GET /api/v1/forms/{form}/sample`

A sample `submission.created` event for building a Zap or workflow, as a one-item list. It is the latest submission's event, or one made up from the schema when the form has none.

```json title="200 response (no submissions yet)"
{
  "data": [
    {
      "id": "evt_sample",
      "type": "submission.created",
      "sandbox": true,
      "form": { "id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "name": "Contact" },
      "submission": { "id": "sample", "data": { "name": "Example", "email": "Example" } }
    }
  ]
}
```

## Partial submissions and analytics

### `GET /api/v1/forms/{form}/partials`

A form's [partial submissions](https://docs.getquickforms.com/partial-submissions/), most recently active first. Filter with `status` (`in_progress`, `abandoned` or `completed`) and `sandbox` (`true` or `false`). Paginated with `per_page` (1 to 100, default 25) and `page`.

```json title="200 response"
{
  "data": [
    {
      "id": "01jce1...",
      "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
      "status": "abandoned",
      "sandbox": false,
      "data": { "name": "Ada" },
      "fields_filled": 1,
      "last_field": "name",
      "link_id": null,
      "submission_id": null,
      "meta": { "...": "..." },
      "started_at": "2026-11-06T10:00:00.000000Z",
      "last_activity_at": "2026-11-06T10:01:00.000000Z",
      "abandoned_at": "2026-11-06T10:31:00.000000Z",
      "completed_at": null
    }
  ],
  "links": { "...": "..." },
  "meta": { "...": "..." }
}
```

### `GET /api/v1/partials/{partial}`

One partial submission.

### `DELETE /api/v1/partials/{partial}`

Delete a partial submission. Responds `204`.

### `GET /api/v1/forms/{form}/funnel`

Started, completed and abandoned counts from partial submissions over the last `days` (1 to 365, default 30), and `drop_off`: the last field people touched before abandoning, most common first. Live only, unless you pass `sandbox=true`. `completion_rate` is `null` when nothing was started.

```json title="200 response"
{
  "data": {
    "started": 4,
    "completed": 1,
    "abandoned": 3,
    "in_progress": 0,
    "completion_rate": 0.25,
    "drop_off": [{ "field": "name", "label": "Name", "abandoned": 2 }]
  }
}
```

### `GET /api/v1/forms/{form}/analytics`

Anonymous [analytics](https://docs.getquickforms.com/analytics/) over the last `days` (1 to 365, default 30): views, unique visitors, starts (live partial submissions), live submissions and conversion rate (submissions over visitors, `null` with no visitors), a daily `series`, and the top 10 `sources` (`utm_source`), `campaigns` (`utm_campaign`) and `referrers` (domain) behind live submissions. `click_ids` counts live submissions by ad network click ID. See [tracking](https://docs.getquickforms.com/tracking/).

```json title="200 response"
{
  "data": {
    "days": 7,
    "views": 3,
    "visitors": 2,
    "starts": 0,
    "submissions": 2,
    "conversion_rate": 1,
    "series": [{ "day": "2026-11-06", "views": 3, "visitors": 2, "submissions": 2 }, "..."],
    "sources": [{ "value": "newsletter", "submissions": 2 }],
    "campaigns": [],
    "referrers": [{ "value": "news.example.com", "submissions": 1 }],
    "click_ids": { "Google Ads": 1 }
  }
}
```

`click_ids` uses the labels Google Ads (`gclid`), Meta (`fbclid`), Microsoft Ads (`msclkid`), TikTok (`ttclid`), LinkedIn (`li_fat_id`) and X (`twclid`), and leaves out networks with no submissions.

## Mail providers

### `GET /api/v1/mail-providers`

The team's mail providers with masked credentials. Not paginated.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/mail-providers \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": [
    { "id": "01jc8y0a7n...", "name": "Main", "driver": "postmark", "is_default": true, "...": "..." }
  ]
}
```

### `POST /api/v1/mail-providers`

Add a provider. `name`, `driver`, `credentials` and `from_address` are required; `from_name` and `is_default` are optional. The credential keys depend on the provider (see [the providers table](https://docs.getquickforms.com/email/#bring-your-own-keys)). Making one the default unsets the previous default.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/mail-providers \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Main", "driver": "postmark", "credentials": {"api_key": "YOUR_POSTMARK_KEY"}, "from_address": "hello@yourdomain.com", "from_name": "Your company", "is_default": true}'
```

### 201 response

```json
{
  "data": {
    "id": "01jc8y0a7n...",
    "name": "Main",
    "driver": "postmark",
    "driver_label": "Postmark",
    "credentials": { "api_key": "••••••••••••••••••••••••••••••••4f2a" },
    "from_address": "hello@yourdomain.com",
    "from_name": "Your company",
    "is_default": true,
    "created_at": "2026-11-06T10:00:00.000000Z"
  }
}
```

### `GET /api/v1/mail-providers/{mailProvider}`

One provider with masked credentials.

### Request

```bash
curl https://YOUR-QUICKFORMS-HOST/api/v1/mail-providers/PROVIDER_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

### 200 response

```json
{
  "data": {
    "id": "01jc8y0a7n...",
    "name": "Main",
    "driver": "postmark",
    "driver_label": "Postmark",
    "credentials": { "api_key": "••••••••••••••••••••••••••••••••4f2a" },
    "from_address": "hello@yourdomain.com",
    "from_name": "Your company",
    "is_default": true,
    "created_at": "2026-11-06T10:00:00.000000Z"
  }
}
```

### `DELETE /api/v1/mail-providers/{mailProvider}`

Delete a provider. Responds `204`. There is no update endpoint: delete and add again to rotate keys.

### Request

```bash
curl -X DELETE https://YOUR-QUICKFORMS-HOST/api/v1/mail-providers/PROVIDER_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
```

Source: https://docs.getquickforms.com/api/index.mdx
