Webhooks

Receive, verify, and respond to Formiary webhook deliveries.

A webhook integration POSTs each new submission to an HTTPS endpoint you control. Every delivery is signed so you can verify it genuinely came from Formiary.

Setup

  1. Open a form and go to the Integrations tab.
  2. Add a Webhook integration.
  3. Enter the HTTPS endpoint URL each submission should be POSTed to.
  4. Choose which events to subscribe to.
  5. On creation, Formiary shows a signing secret.
The signing secret is shown only once, on creation. Copy and store it securely — it can't be retrieved later. You'll use it to verify signatures.

Request headers

Every delivery includes these headers:

  • Content-Type: application/json
  • X-Formiary-Event: submission.created — the event type.
  • X-Formiary-Signature: sha256=<hex> — an HMAC-SHA256 of the raw request body bytes, keyed by your signing secret. The body is canonical JSON (sorted keys, compact separators); always verify against the raw bytes you receive, not a re-serialized object.

Payload envelope

{
  "event": "submission.created",
  "meta": {
    "deliveryId": "",
    "formIntegrationId": "",
    "formId": "",
    "submissionId": null,        // null for test events
    "timestamp": "2026-06-15T…",
    "test": true                 // present and true only for test events
  },
  "data": { "submission": { "id": "", "values": [] } }
}

Verifying the signature

Compute an HMAC-SHA256 of the raw request body using your signing secret and compare it to the X-Formiary-Signature header. Reject any request whose computed signature doesn't match.

import crypto from 'node:crypto';

// Mount with a raw body parser so you verify the exact bytes received.
app.post('/webhooks/formiary', express.raw({ type: 'application/json' }), (req, res) => {
  const received = req.header('X-Formiary-Signature') ?? '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.FORMIARY_SIGNING_SECRET)
    .update(req.body) // raw request bytes, not a re-serialized object
    .digest('hex');

  const valid = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.status(401).send('invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // handle event.event / event.data ...
  res.sendStatus(200);
});

Responding, retries & dead-lettering

Respond with a 2xx status to acknowledge a delivery. Any other status (or a connection error / timeout) marks the attempt as failed and schedules a retry with backoff.

After repeated failures a delivery is dead-lettered and stops retrying automatically. You can manually retry a failed or dead-lettered delivery from its row in the delivery log. A manual retry preserves the attempt counter, so retrying a dead-lettered delivery grants exactly one more attempt before it re-dead-letters.

Deliveries are at-least-once: the same submission may be delivered more than once. Use meta.deliveryId (or the submission id) to deduplicate on your side.

Testing

You can send a test event to your endpoint from the integration. Test events have meta.test: true and a null submissionId, so your handler can recognize and ignore them if needed.

Delivery log

Each integration keeps a log of delivery attempts with their status (pending, in flight, success, failed, or dead-lettered) and the response received. Use it to debug failures and to trigger manual retries.