Skip to content

REST API reference

Every endpoint of the quickforms REST API, with request and response examples.

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.
  • 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. 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.

Method Path Section
GET /api/v1/me Account
GET /api/v1/forms Forms
POST /api/v1/forms Forms
GET /api/v1/form-templates Forms
POST /api/v1/forms/generate Forms
GET /api/v1/forms/{form} Forms
PATCH /api/v1/forms/{form} Forms
POST /api/v1/forms/{form}/publish Forms
DELETE /api/v1/forms/{form}/draft Forms
POST /api/v1/forms/{form}/webhook-secret/rotate Forms
POST /api/v1/forms/{form}/sandbox-key/rotate Forms
DELETE /api/v1/forms/{form} Forms
POST /api/v1/forms/{form}/archive Forms
POST /api/v1/forms/{form}/restore Forms
PUT /api/v1/forms/by-key/{key} Forms as code
GET /api/v1/forms/by-key/{key} Forms as code
POST /api/v1/forms/{form}/links Personal links
GET /api/v1/forms/{form}/links Personal links
GET /api/v1/links/{link} Personal links
DELETE /api/v1/links/{link} Personal links
POST /api/v1/forms/{form}/test-submissions Submissions
GET /api/v1/forms/{form}/submissions Submissions
GET /api/v1/forms/{form}/export Submissions
GET /api/v1/submissions/{submission} Submissions
POST /api/v1/submissions/{submission}/review Submissions
POST /api/v1/submissions/{submission}/confirm Submissions
POST /api/v1/submissions/{submission}/resend-confirmation Submissions
GET /api/v1/submissions/{submission}/deliveries Deliveries
GET /api/v1/deliveries/{delivery} Deliveries
POST /api/v1/deliveries/{delivery}/redeliver Deliveries
GET /api/v1/destination-types Destinations
GET /api/v1/forms/{form}/destinations Destinations
POST /api/v1/forms/{form}/destinations Destinations
GET /api/v1/destinations/{destination} Destinations
PATCH /api/v1/destinations/{destination} Destinations
DELETE /api/v1/destinations/{destination} Destinations
POST /api/v1/destinations/{destination}/preview Destinations
POST /api/v1/hooks Automation hooks
DELETE /api/v1/hooks/{hook} Automation hooks
GET /api/v1/forms/{form}/sample Automation hooks
GET /api/v1/forms/{form}/partials Partial submissions and analytics
GET /api/v1/partials/{partial} Partial submissions and analytics
DELETE /api/v1/partials/{partial} Partial submissions and analytics
GET /api/v1/forms/{form}/funnel Partial submissions and analytics
GET /api/v1/forms/{form}/analytics Partial submissions and analytics
GET /api/v1/mail-providers Mail providers
POST /api/v1/mail-providers Mail providers
GET /api/v1/mail-providers/{mailProvider} Mail providers
DELETE /api/v1/mail-providers/{mailProvider} Mail providers

Account

GET /api/v1/me

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

curl https://YOUR-QUICKFORMS-HOST/api/v1/me \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

curl https://YOUR-QUICKFORMS-HOST/api/v1/forms?per_page=10 \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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, 1 to 64 lowercase letters, numbers, dashes and underscores), template (a key from GET /api/v1/form-templates: the template’s fields and settings are used and anything else you pass overrides them, see creating forms; 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), mode (live or sandbox), is_active, webhook_url, mail_provider_id, settings (see form settings), turnstile_secret (see Turnstile keys), stripe_secret (see Stripe keys) and draft. With "draft": true the form is created unpublished (status draft): it takes sandbox submissions only until you publish it. Without it the form is published straight away. See drafts and publishing. The sandbox key and webhook secret are generated for you.

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"]}}}'
{
  "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. Each has a key, name, summary, category, fields (the field labels) and the full draft (name, description, schema and settings). Not paginated.

curl https://YOUR-QUICKFORMS-HOST/api/v1/form-templates \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

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"]}}}'
{
  "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. The 422 above is what an unknown key returns.

POST /api/v1/forms/generate

Design a form with 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.

The draft has a name, a description, a schema and settings with a success message, an autoresponder and up to 3 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.

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}'
{
  "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"] }
      }
    }
  }
}
{
  "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.

curl https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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. 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.

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"]}}'
{
  "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.

curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/publish \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

curl -X DELETE https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/draft \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

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.

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 it instead. Every form response has can_delete.

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.

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.

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 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, 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.

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/..."}}]}'
{
  "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.

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

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

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. 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.
honeypot_field null Overrides the main honeypot name (_gotcha).
honeypot_fields [] Up to 5 extra honeypots, such as website or fax.
turnstile.enabled false Require a valid 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 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: 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 as spam.
payment.enabled false Take a Stripe payment 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 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.
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: 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. Sandbox submissions don’t need one.
hosted.enabled true Serve the hosted form page 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 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, 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.

422 responsejson
{
  "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, 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:

422 responsejson
{
  "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 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.

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.

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:

422 responsejson
{
  "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.

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.

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"}}'
{
  "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).

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.

One 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, if it has one. Validation errors come back as a 422.

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"}}'
{
  "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, false for ones that followed them. Omit for both.
confirmation pending, confirmed or expired, for forms with double opt-in. Omit for all.
delivery Submissions with a delivery that is failed, retrying, or issue for either. Omit for all.
link_id Only submissions made through this personal link.
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.
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"
{
  "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.
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.

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 (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 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 (or there was no address to confirm), otherwise pending, confirmed or expired, and confirmed_at is when it was confirmed.

curl https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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, marking a flagged submission as not spam sends the confirmation email first. The rest is held until the person confirms.

See flagged submissions.

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}'
{
  "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: 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.

curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID/confirm \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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: 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.

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
GET /f/{form} The hosted form page. Hosted forms
GET /f/{form}/embed The form page for an <iframe>. Embed
GET /f/{form}/schema.json The published schema the embed widget renders. Embed
POST /f/{form}/uploads Get a pre-signed URL to upload a file directly. Direct uploads
POST /f/{form}/payment Check the answers and create a Stripe PaymentIntent, before submitting. Payments
POST /f/{form}/progress Save progress while someone fills the form in. Partial submissions
POST /f/{form}/view Count an anonymous view. Analytics
GET /f/{form}/altcha A fresh Altcha challenge. 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.

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"}'
{
  "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 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.

curl https://YOUR-QUICKFORMS-HOST/api/v1/submissions/SUBMISSION_ID/deliveries \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

curl https://YOUR-QUICKFORMS-HOST/api/v1/deliveries/DELIVERY_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/deliveries/DELIVERY_ID/redeliver \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json"
{
  "data": { "id": "01jca9qf4bx2c3d4e5f6g7h8j9", "status": "pending", "next_retry_at": null, "...": "..." }
}

Destinations

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.

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"}]}'
{
  "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.

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"}}'
{
  "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. 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.

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.

200 response (no submissions yet)json
{
  "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, 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.

200 responsejson
{
  "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.

200 responsejson
{
  "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 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.

200 responsejson
{
  "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.

curl https://YOUR-QUICKFORMS-HOST/api/v1/mail-providers \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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). Making one the default unsets the previous default.

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}'
{
  "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.

curl https://YOUR-QUICKFORMS-HOST/api/v1/mail-providers/PROVIDER_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/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.

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

Type to search…

↑↓ navigate↵ selectEsc close