# Webhooks

Last updated: 2026-07-07

Canonical human page: https://fillo.so/docs/webhooks
Quickstart: https://fillo.so/docs/embed.md
Code-defined forms: https://fillo.so/docs/authoring.md
Styling: https://fillo.so/docs/styling.md
Prefill: https://fillo.so/docs/prefill.md
Custom UI: https://fillo.so/docs/custom-ui.md
API reference: https://fillo.so/docs/reference.md
Troubleshooting: https://fillo.so/docs/troubleshooting.md

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 response POSTs JSON with these headers:

| Header | Value |
| --- | --- |
| Content-Type | application/json |
| X-Fillo-Event | response.created (the only event today) |
| X-Fillo-Signature | Hex HMAC-SHA256 of the raw request body, keyed with your signing secret |
| X-Fillo-Delivery-Id | Stable across retries of the same delivery — receivers can dedupe on it |

## Payload

The payload carries the same data twice: flat Zapier-style keys at the top level, and a nested `form` + `response` shape. Use whichever reads better — new fields land in both.

```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,
  "answers": {
    "reason": "crash",
    "details": "Export hung at 90%",
    "email": "ada@example.com"
  },
  "formatted": {
    "reason": "It crashed",
    "details": "Export hung at 90%",
    "email": "ada@example.com"
  },
  "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%" },
    { "id": "email", "label": "Email me when it's fixed", "kind": "email",
      "value": "ada@example.com", "formatted": "ada@example.com" }
  ],
  "files": [],
  "meta": { "source": "app.example.com/convert", "duration_ms": 8200 },
  /* Same data again, nested — "form": { id, slug, name }, "response": { id, data,
     answers, formatted, fields, files, meta, createdAt }. Use whichever shape reads better. */
  "form": { … },
  "response": { … }
}
```

`answers` is keyed by field id with raw values; `formatted` holds display strings (option labels, not ids). 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.

## 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" }), (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 event = JSON.parse(req.body.toString("utf8"));
  // Deliveries are at-least-once — key your side effects on event.response.id.
  console.log("new response", event.response.id);
  res.status(200).end();
});
```

## Delivery and retries

- The first attempt fires immediately after the response is stored.
- 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. Make handlers idempotent — key on `response.id` (or `X-Fillo-Delivery-Id`).
