---
title: "Webhooks"
description: "Every webhook event and payload shape, how to verify the signature with your signing secret, and how retries work."
---

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

# Webhooks

Set a `webhook_url` on a form and every accepted submission is posted to it as signed JSON. You can [choose other events](#events) too.

This page is also available as Markdown for coding agents at `/webhooks/index.md` on this site. `/llms.txt` lists every docs page and `/llms-full.txt` holds them all in one file.

## Set up a receiver

1. Add your endpoint as the **Webhook URL** in the form's settings, or set `webhook_url` through the [API](https://docs.getquickforms.com/api/) or [MCP](https://docs.getquickforms.com/mcp/).
2. Copy the form's signing secret (`whsec_...`) and keep it in your receiver's environment, for example as `QUICKFORMS_WEBHOOK_SECRET`.
3. [Verify the `Quickforms-Signature` header](#verify-the-signature) against the raw request body.
4. Return any 2xx status within 10 seconds. Do slow work after responding.
5. Use the event's `id` to [ignore duplicates](#retries-and-duplicates).

Each form has its own secret. Dashboard form pages always show it. The API returns it only when a form is created or when you [rotate it](#rotate-the-secret): `GET` and `PATCH` responses and `get-form` leave it out.

## Events

| Event | Sent when | Selected by default |
| --- | --- | --- |
| `submission.created` | A submission has been accepted and is ready to deliver. | Yes |
| [`submission.held`](#submissionheld) | A submission was held for review. It will often contain spam. | No |
| [`submission.deleted`](#submissiondeleted) | A submission was deleted. | No |
| [`submission.confirmation_expired`](#submissionconfirmation_expired) | A double opt-in link ran out without being clicked. | No |
| [`payment.needs_attention`](#paymentneeds_attention) | Someone paid, but the submission couldn't be stored. | No |

Choose events under **Events to send** in the form's webhook settings, or set `settings.webhook.events` through the [API](https://docs.getquickforms.com/api/) or [MCP](https://docs.getquickforms.com/mcp/) to a list of event names. It replaces the whole list. `null` means the default, and an empty list sends nothing. The list is part of the form's settings, so a draft change takes effect when you publish.

The event name is in the `type` field and the `Quickforms-Event` header. Ignore any `type` your receiver doesn't handle.

Each event is sent once. Retries and manual redelivery repeat that same event.

### When `submission.created` is sent

| Situation | What happens |
| --- | --- |
| A normal submission | Sent once it is stored. |
| A form with the [payment step](https://docs.getquickforms.com/payments/) | Sent after the payment succeeds and the submission is stored. |
| A form with [double opt-in](https://docs.getquickforms.com/double-opt-in/) | Sent after the person confirms. Not sent if they never do. |
| [Held for review](https://docs.getquickforms.com/spam-filter/) | Not sent unless you mark it as not spam, and then sent at that point. `submission.held` is sent at the start if you selected it. |
| A [sandbox](https://docs.getquickforms.com/sandbox/) submission | Never posted. The delivery is recorded as `simulated`, with the full request. The other events are not simulated, so sandbox submissions never produce them. |
| A [partial submission](https://docs.getquickforms.com/partial-submissions/) | Never sent. |

## Request

Every delivery is a `POST` with these headers.

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `quickforms-webhooks/1.0` |
| `Quickforms-Event` | The event name |
| `Quickforms-Submission` | The submission ID. Left out of `payment.needs_attention`, which has no submission. |
| `Quickforms-Signature` | `t=<unix timestamp>,v1=<hex signature>` |

This is the body of `submission.created`. The [other events](#other-events) use the same outer fields.

```json title="POST body"
{
  "id": "evt_01jca9qf3tz5b8m2k7w4r6x0hn",
  "type": "submission.created",
  "created_at": "2026-11-06T10:24:00+00:00",
  "sandbox": false,
  "form": { "id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "name": "Contact" },
  "submission": {
    "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "data": { "name": "Ada Lovelace", "email": "ada@example.com", "message": "Hello" },
    "confirmed_at": null,
    "link": null,
    "tracking": { "utm_source": "newsletter", "utm_campaign": "spring" },
    "payment": null,
    "created_at": "2026-11-06T10:24:00+00:00"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Unique to the event, and the same across retries and redelivery. On `submission.created` it is `evt_` plus the submission ID. |
| `type` | string | The event name. |
| `created_at` | string | ISO 8601 time of the event. On `submission.created` it is when the submission was made. It doesn't change between retries. |
| `sandbox` | boolean | Always `false` on a real delivery. It is `true` in simulated deliveries and in the sample that [Zapier and n8n](https://docs.getquickforms.com/zapier-and-n8n/) load. |
| `form.id`, `form.name` | string | The form. |
| `submission.id` | string | The submission. Use it with the [API](https://docs.getquickforms.com/api/#submissions). |
| `submission.data` | object | The answers, keyed by field name. See [field values](#field-values). |
| `submission.confirmed_at` | string or null | When the person confirmed on a form with double opt-in, otherwise `null`. |
| `submission.link` | object or null | The [personal link](#personal-links) it came through. |
| `submission.tracking` | object or null | [Attribution](#tracking) the page sent. |
| `submission.payment` | object or null | The [Stripe payment](#payments). |
| `submission.created_at` | string | ISO 8601 time the submission was made. |

### Field values

`submission.data` has one key for every field on the published form, including optional fields left empty. Empty answers are `null`.

| Field type | JSON value | Example |
| --- | --- | --- |
| `text`, `textarea`, `email`, `url`, `tel`, `hidden` | string | `"Ada Lovelace"` |
| `number` | number | `42` |
| `date` | string | `"2026-11-06"` |
| `select`, `radio` | string, the option's value | `"sales"` |
| `select` with `multiple`, `checkboxes` | array of option values | `["sales", "support"]` |
| `checkbox` | boolean, `false` when unticked | `true` |
| `file` | object, see below | |

### Files

A `file` field holds the file's details and a signed download link. The file itself is never in the webhook.

```json title="A file field in submission.data"
{
  "cv": {
    "name": "ada-cv.pdf",
    "size": 482133,
    "mime": "application/pdf",
    "url": "https://YOUR-QUICKFORMS-DOMAIN/files/01jca9qf3tz5b8m2k7w4r6x0hn/cv?expires=1794479200&signature=..."
  }
}
```

The link works for 3 days by default (`QUICKFORMS_FILE_LINK_DAYS`), so download the file when you receive the event if you need to keep it. A retry or redelivery sends the original body, so its link can already have expired. [`GET /api/v1/submissions/{id}`](https://docs.getquickforms.com/api/#submissions) always returns a fresh one. See [file uploads](https://docs.getquickforms.com/file-uploads/) for limits and storage.

### Payments

`submission.payment` is set on forms with the [payment step](https://docs.getquickforms.com/payments/). `amount` is in the currency's smallest unit, as Stripe reports it, so this is 25.00 USD.

```json title="submission.payment"
{ "intent": "pi_3QabcDEF", "amount": 2500, "currency": "usd", "status": "succeeded" }
```

### Personal links

`submission.link` is the [personal link](https://docs.getquickforms.com/personal-links/) the submission came through. It is the same object as the submission's `meta.link`: the link's `id`, `label`, `email` and your `metadata`, with empty keys left out.

```json title="submission.link"
{ "id": "01jcb2m4k8q6r7s9t0v1w2x3y4", "label": "Ada Lovelace", "email": "ada@example.com", "metadata": { "customer_id": "cus_123" } }
```

### Tracking

`submission.tracking` holds what the page sent (see [tracking](https://docs.getquickforms.com/tracking/)), or `null`. Only keys with a value are included: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `utm_id`, `gclid`, `gbraid`, `wbraid`, `fbclid`, `msclkid`, `ttclid`, `twclid`, `li_fat_id`, `rdt_cid`, `referrer` and `landing_url`. The tracking script also adds `first` and `last` objects with the same keys for first and last touch.

## Other events

These have the same outer `id`, `type`, `created_at`, `sandbox` and `form` fields, the same headers and signature, and the same retries.

Their `id` is new for each event, so one submission's events never share one. Deliveries for these events belong to the form, so they appear under Logs with their event name, and not on the submission's Activity tab.

### `submission.held`

Sent when a submission is held for review. `submission` is the same object as in `submission.created`, plus `review`.

```json title="submission.held"
{
  "id": "evt_01jcbe0h5y7n3p9q2r4s6t8v0w",
  "type": "submission.held",
  "created_at": "2026-11-06T10:24:00+00:00",
  "sandbox": false,
  "form": { "id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "name": "Contact" },
  "submission": {
    "id": "01jca9qf3tz5b8m2k7w4r6x0hn",
    "data": { "name": "Ada Lovelace", "email": "ada@example.com", "message": "Buy cheap watches" },
    "confirmed_at": null,
    "link": null,
    "tracking": null,
    "payment": null,
    "created_at": "2026-11-06T10:24:00+00:00",
    "review": {
      "spam": true,
      "failed_rules": false,
      "filters": [],
      "broken_rules": [],
      "reasons": ["Looks like spam (92% likely)"]
    }
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `review.spam` | boolean | It failed the base spam check, or a country or disposable email filter. |
| `review.failed_rules` | boolean | It broke one of the form's smart rules. |
| `review.filters` | array of strings | The country or disposable email filters it matched. |
| `review.broken_rules` | array of strings | The smart rules it broke. |
| `review.reasons` | array of strings | Plain-English reasons it was held. |

If you later mark it as not spam, `submission.created` is sent with the same submission ID.

### `submission.deleted`

Sent when a live submission is deleted, including by the `delete` expiry option on a double opt-in form. It holds IDs and the time only, never the answers.

```json title="submission.deleted"
{
  "id": "evt_01jcbe2k8z1m4n6p3q5r7s9t0v",
  "type": "submission.deleted",
  "created_at": "2026-11-08T09:12:00+00:00",
  "sandbox": false,
  "form": { "id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "name": "Contact" },
  "submission": { "id": "01jca9qf3tz5b8m2k7w4r6x0hn", "created_at": "2026-11-06T10:24:00+00:00" }
}
```

It can be for a submission your receiver never saw, such as one that was held or never confirmed. Treat an unknown `submission.id` as nothing to do. It isn't sent when a whole form or team is deleted.

### `submission.confirmation_expired`

Sent when a [double opt-in](https://docs.getquickforms.com/double-opt-in/) link runs out without being clicked. `submission` is the same object as in `submission.created`, with `confirmed_at` set to `null`.

If the form deletes expired submissions (`confirmation.on_expiry` set to `delete`), this is sent first and any file links in it stop working once the submission is gone. `submission.deleted` follows if you selected it.

### `payment.needs_attention`

Sent when a payment went through but quickforms [couldn't turn it into a submission](https://docs.getquickforms.com/payments/#if-a-payment-is-never-finished), so a person needs to look at it. There is no submission, so there's no `submission` object or `Quickforms-Submission` header.

```json title="payment.needs_attention"
{
  "id": "evt_01jcbe4m2a6p8q1r3s5t7v9w0x",
  "type": "payment.needs_attention",
  "created_at": "2026-11-06T10:40:00+00:00",
  "sandbox": false,
  "form": { "id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "name": "Contact" },
  "payment": {
    "id": "01jcbd9y7w5v3t1s0r8q6p4n2m",
    "intent": "pi_3QabcDEF",
    "amount": 2500,
    "currency": "usd",
    "reason": "Stripe would not show this payment with the saved key, so it may have been paid.",
    "created_at": "2026-11-06T10:24:00+00:00"
  },
  "answers": { "name": "Ada Lovelace", "email": "ada@example.com" }
}
```

`payment.id` is the pending payment's record ID. `amount` is in the currency's smallest unit. `answers` holds the answers saved before payment, keyed by field name, and has no files.

## Verify the signature

The signing secret proves a request came from quickforms and wasn't changed. To check a request:

1. Split the `Quickforms-Signature` header on `,` to get `t` (a Unix timestamp) and `v1` (a hex signature).
2. Reject the request if `t` is more than five minutes from now. This blocks replays.
3. Compute the hex HMAC SHA-256 of `<t>.<raw body>`, using the form's signing secret as the key.
4. Compare your result with `v1` in constant time. Reject the request if they differ.

`t` is set for each attempt, so retries pass the five minute check.

Sign the raw body bytes exactly as received. Parsing the JSON and serialising it again changes the bytes and the check fails, so read the body before any JSON middleware touches it.

### Node

```js title="verify.js"
import crypto from "node:crypto";

// Use the raw request body, exactly as received, not a re-serialised object.
export function verifyQuickforms(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.trim().split("=", 2)),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1 ?? "";

  if (!timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

// Express: app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verifyQuickforms(req.body.toString("utf8"), req.get("Quickforms-Signature"), process.env.QUICKFORMS_WEBHOOK_SECRET);
//   if (!ok) return res.sendStatus(400);
//   res.sendStatus(200);
// });
```

### PHP

```php title="verify.php"
<?php

function verify_quickforms(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, null);
        $parts[$key] = $value;
    }

    $timestamp = (int) ($parts['t'] ?? 0);
    $signature = (string) ($parts['v1'] ?? '');

    if (! $timestamp || ! $signature || abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    return hash_equals($expected, $signature);
}

// Laravel: verify_quickforms($request->getContent(), $request->header('Quickforms-Signature'), config('services.quickforms.secret'));
```

### Python

```python title="verify.py"
import hashlib
import hmac
import time


def verify_quickforms(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    timestamp = int(parts.get("t", "0") or 0)
    signature = parts.get("v1", "")

    if not timestamp or not signature or abs(time.time() - timestamp) > tolerance:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature)
```

### Check your code with a known signature

These values produce this signature. Use them in a test, with the five minute check turned off because the timestamp is old.

| Input | Value |
| --- | --- |
| Secret | `whsec_example_secret` |
| Raw body | `{"id":"evt_example","type":"submission.created"}` |
| Header | `t=1794220000,v1=ab8e24441a0c15d05e85be9f594a7e39b385abb14636ba2acb4f95f355eed80a` |

### Test with a sandbox submission

**Send a test** in the dashboard, `send-test-submission` over MCP and the API's sandbox mode never post to your endpoint. The simulated delivery shows the exact body and `Quickforms-Signature` header your receiver would have got, signed with your real secret. Replay that body and header to your receiver with the timestamp check relaxed, because the timestamp is from when the test ran. See [sandbox](https://docs.getquickforms.com/sandbox/).

To exercise your endpoint for real, make a live submission.

### Rotate the secret

Rotate a form's secret with **Rotate** beside it in the form's settings, `POST /api/v1/forms/{form}/webhook-secret/rotate`, or the `rotate-webhook-secret` MCP tool. Each returns or shows the new secret. The old one stops working straight away, and retries of earlier deliveries are signed with the new one.

## Retries and duplicates

- Any 2xx response is a success. Other statuses, redirects (they are not followed), timeouts and connection errors are failures.
- Failures are retried after 10s, 1m, 5m, 30m, 2h and 6h, so a delivery gets up to seven attempts before it is marked failed. The request timeout is 10 seconds. A retry more than 15 minutes away can run a few minutes after its time.
- Delivery can repeat. If your receiver does the work but responds too slowly, the event is sent again. Store the event `id` and skip any you have already handled.
- Events can arrive out of order, because a retry of one event can land after a later one. Use `created_at` if order matters.
- Each attempt records its status code, a response excerpt, the error, the duration and when the next retry is due.
- Delivery statuses are `pending`, `retrying`, `succeeded`, `failed` and `simulated` (sandbox).
- Redeliver any live delivery from the dashboard, the API or MCP. Sandbox deliveries are never sent, so they cannot be redelivered.
- Finished deliveries keep their payload for 30 days and attempts are kept for 90 days by default (`QUICKFORMS_DELIVERY_PAYLOAD_RETENTION_DAYS` and `QUICKFORMS_DELIVERY_ATTEMPT_RETENTION_DAYS`). A delivery whose payload has been cleared cannot be redelivered.
- URLs that resolve to private or reserved network addresses are refused and not retried. Every IPv4 and IPv6 address the host resolves to is checked, and the request is sent to those checked addresses.
- Only the first 64 KB of a response is read into the delivery log.

## Sending the same event elsewhere

An [HTTP destination](https://docs.getquickforms.com/destinations/) with its body set to `event` sends the `submission.created` JSON, and signs it with the form's webhook secret when `sign` is on. Use one to send that event to more than one endpoint.

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