Webhooks
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. The signing secret is shown once, when the webhook is created. Each delivery POSTs JSON with these headers:
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Fillo-Event | response.created, response.updated, or draft.abandoned |
| X-Fillo-Signature | Hex HMAC-SHA256 of the raw request body, keyed with your signing secret |
| X-Fillo-Delivery-Id | Stable 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. Fillo encrypts that credential at rest and never shows it again. It is separate from the 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 update event is emitted when an identified person edits a response in place. Enable drafts on an individual webhook to also receive draft.abandoned, a signal that carries no answer content. Branch on the event header or body, and use X-Fillo-Delivery-Id for deduplication.
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 do not have identical keys, so choose fields explicitly. On an update event, submitted_at and response.createdAt remain the response’s original creation time.
{
"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"
}
}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 workspace HMAC check passed. File uploads appear in files with name, size, mime, and a download url that requires a signed-in workspace member.
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, its email and name may still be null, and verified is always true. The event never contains answers, fields, files, or a response object.
{
"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:
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 — about 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours — up to 6 attempts total. Requests never follow redirects, and production deliveries are HTTPS-only.
This page for agents: /docs/webhooks.md · index at /llms.txt
Updated