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