Skip to main content

Webhooks

Response events you can trust: signed payloads, at-least-once delivery with retries, and delivery state you can check when something looks off.

Deliver every response to your own backend: configure a webhook per form, verify its signature, and handle at-least-once retries.

Configure

Configure webhooks per form under Form settings → Webhooks in the dashboard. The signing secret is shown once, when the webhook is created. Each delivery POSTs JSON with these headers:

HeaderValue
Content-Typeapplication/json
X-Fillo-Eventresponse.created, response.updated, or draft.abandoned
X-Fillo-SignatureHex HMAC-SHA256 of the raw request body, keyed with your signing secret
X-Fillo-Delivery-IdStable across retries of one delivery. Use it as the idempotency key

If the receiving service requires its own credential, choose Bearer token or X-API-Key when you add the webhook. Bearer sends Authorization: Bearer <token>; X-API-Key sends the key in X-API-Key. Fillo encrypts this receiver credential at rest and never returns it. It is separate from Fillo's signing secret: receiver authentication lets Fillo enter the endpoint, while X-Fillo-Signature lets the endpoint verify Fillo. When a Grok Bot webhook routine gives you a sender key, choose the authentication method it requests and paste that key here.

Every webhook receives response.created and response.updated (the latter when a form runs in update mode — a response limit with onRepeat: "update" — and someone edits their response). Tick drafts on a webhook to also receive draft.abandoned — emitted when a respondent leaves a save-and-resume form idle. It's a signal, not content: the payload carries the form, the draft's page and timestamps, and the verified respondent id where known, never any answers. Branch on event (header or body) and dedupe on X-Fillo-Delivery-Id.

Payload

response.created and response.updated share one response-event shape. The top level is a Zapier-compatible projection; form and response provide a related nested view. The views overlap, but they do not have identical keys, so choose fields explicitly rather than assuming every top-level key is duplicated. On response.updated, submitted_at and response.createdAt remain the response's original creation time.

json
{
  "event": "response.created",
  "id": "wA3kR9tL0qBn",
  "response_id": "wA3kR9tL0qBn",
  "submitted_at": "2026-07-04T09:41:23.512Z",
  "form_id": "Jf2mX8pQ4sDv",
  "form_name": "Conversion failed",
  "form_slug": "conversion-failed",
  "form_url": "https://fillo.so/f/conversion-failed",
  "source": "app.example.com/convert",
  "duration_ms": 8200,
  "respondent": {
    "id": "user_1042",
    "email": "ada@example.com",
    "name": "Ada",
    "verified": true
  },
  "answers": {
    "reason": "crash",
    "details": "Export hung at 90%"
  },
  "formatted": {
    "reason": "It crashed",
    "details": "Export hung at 90%"
  },
  "fields": [
    {
      "id": "reason",
      "label": "What went wrong?",
      "kind": "select",
      "value": "crash",
      "formatted": "It crashed"
    },
    {
      "id": "details",
      "label": "Tell us more",
      "kind": "long_text",
      "value": "Export hung at 90%",
      "formatted": "Export hung at 90%"
    }
  ],
  "files": [],
  "meta": {
    "source": "app.example.com/convert",
    "duration_ms": 8200
  },
  "form": {
    "id": "Jf2mX8pQ4sDv",
    "slug": "conversion-failed",
    "name": "Conversion failed"
  },
  "response": {
    "id": "wA3kR9tL0qBn",
    "data": {
      "reason": "crash",
      "details": "Export hung at 90%"
    },
    "answers": {
      "reason": "crash",
      "details": "Export hung at 90%"
    },
    "formatted": {
      "reason": "It crashed",
      "details": "Export hung at 90%"
    },
    "fields": [
      {
        "id": "reason",
        "label": "What went wrong?",
        "kind": "select",
        "value": "crash",
        "formatted": "It crashed"
      },
      {
        "id": "details",
        "label": "Tell us more",
        "kind": "long_text",
        "value": "Export hung at 90%",
        "formatted": "Export hung at 90%"
      }
    ],
    "files": [],
    "meta": {
      "source": "app.example.com/convert",
      "duration_ms": 8200
    },
    "createdAt": "2026-07-04T09:41:23.512Z"
  }
}

For response events, answers is keyed by field id with raw values; formatted holds display strings (option labels, not ids). respondent is null when no identity was recorded; otherwise its verified flag says whether the project HMAC check passed. File uploads appear in files as { file_id, field_id, field_label, name, size, mime, url, requires_auth: true } — the url requires a signed-in workspace member, so forward the reference, not the expectation of a public link.

draft.abandoned has a separate, answer-free shape. draft.page is the zero-based saved page index. respondent is null unless the draft belongs to a verified identity; when present, email and name may still be null and verified is always true. The event never contains answers, fields, files, or a response object.

json
{
  "event": "draft.abandoned",
  "form": {
    "id": "Jf2mX8pQ4sDv",
    "slug": "conversion-failed",
    "name": "Conversion failed"
  },
  "draft": {
    "id": "dR4fT8mK2qLp",
    "page": 1,
    "savedAt": "2026-07-03T09:41:23.512Z",
    "expiresAt": "2026-07-10T09:41:23.512Z"
  },
  "respondent": {
    "id": "user_1042",
    "email": "ada@example.com",
    "name": "Ada",
    "verified": true
  }
}

Verify the signature

Verify the signature against the raw bytes before trusting a request:

js
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();

// Verify against the RAW request bytes — parsing and re-stringifying changes them.
app.post("/hooks/fillo", express.raw({ type: "application/json" }), async (req, res) => {
  const expected = createHmac("sha256", process.env.FILLO_WEBHOOK_SECRET)
    .update(req.body) // Buffer of the raw body
    .digest("hex");
  const given = req.get("X-Fillo-Signature") ?? "";
  const ok =
    given.length === expected.length &&
    timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const deliveryId = req.get("X-Fillo-Delivery-Id");
  if (!deliveryId) return res.status(400).end();

  const event = JSON.parse(req.body.toString("utf8"));
  // insertOnce commits the verified payload to a durable inbox keyed by
  // deliveryId. Duplicate ids are no-ops; storage errors throw so Fillo retries.
  // A separate worker drains the inbox with its own retry policy.
  await deliveryInbox.insertOnce({ deliveryId, event });
  return res.status(200).end();
});

Delivery and retries

  • The first attempt fires immediately after the response is stored. One exception: a form set to hold unverified submissions for review delivers a held submission only when you release it — a normal response.created whose submitted_at keeps the original submission time.
  • A non-2xx status or a 10-second timeout counts as a failure. Failed deliveries retry with backoff — roughly 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours after successive failures — up to 6 attempts total before the delivery is marked failed.
  • Requests never follow redirects, and production deliveries are HTTPS-only.
  • Delivery is at-least-once: a retry can arrive after your server already processed a request whose acknowledgment was lost. Commit the verified payload and X-Fillo-Delivery-Id to a durable inbox with a uniqueness constraint, acknowledge only after that commit, then process the inbox with its own retries. A direct database-only handler can instead record the delivery id and its domain mutation in one transaction. Do not claim an id and then start uncommitted work: a crash between those steps loses the retry. Do not dedupe on response.id: one living response can emit both response.created and later response.updated deliveries.

This page for agents: /docs/webhooks.md · index at /llms.txt

Updated

Was this page helpful?