Skip to content

Webhooks

Every webhook event and payload shape, how to verify the signature with your signing secret, and how retries work.

Set a webhook_url on a form and every accepted submission is posted to it as signed JSON. You can choose other 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 or 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 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.

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: 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 A submission was held for review. It will often contain spam. No
submission.deleted A submission was deleted. No
submission.confirmation_expired A double opt-in link ran out without being clicked. No
payment.needs_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 or 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 Sent after the payment succeeds and the submission is stored.
A form with double opt-in Sent after the person confirms. Not sent if they never do.
Held for review 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 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 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 use the same outer fields.

POST bodyjson
{
  "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 load.
form.id, form.name string The form.
submission.id string The submission. Use it with the API.
submission.data object The answers, keyed by field name. See 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 it came through.
submission.tracking object or null Attribution the page sent.
submission.payment object or null The Stripe payment.
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.

A file field in submission.datajson
{
  "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} always returns a fresh one. See file uploads for limits and storage.

Payments

submission.payment is set on forms with the payment step. amount is in the currency’s smallest unit, as Stripe reports it, so this is 25.00 USD.

submission.paymentjson
{ "intent": "pi_3QabcDEF", "amount": 2500, "currency": "usd", "status": "succeeded" }

submission.link is the personal link 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.

submission.linkjson
{ "id": "01jcb2m4k8q6r7s9t0v1w2x3y4", "label": "Ada Lovelace", "email": "ada@example.com", "metadata": { "customer_id": "cus_123" } }

Tracking

submission.tracking holds what the page sent (see 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.

submission.heldjson
{
  "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.

submission.deletedjson
{
  "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 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, so a person needs to look at it. There is no submission, so there’s no submission object or Quickforms-Submission header.

payment.needs_attentionjson
{
  "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.

verify.jsjs
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);
// });
verify.phpphp
<?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'));
verify.pypython
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.

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

Type to search…

↑↓ navigate↵ selectEsc close