Webhooks
Signed JSON deliveries for every submission — envelope format, HMAC verification, retries and testing.
Webhooks push every accepted submission to a URL you control, as JSON, signed with
a per-webhook secret. Create them per form in the console (or
POST /v1/forms/:id/webhooks with a management key). The signing secret is
returned exactly once at creation; you can rotate it any time.
Envelope
Every delivery — including retries and test pings — is wrapped in the same envelope:
{
"type": "submission.created",
"id": "whd_4n2k8p",
"created_at": "2026-06-10T10:23:01.000Z",
"data": {
"submission_id": "sub_8f3k2j",
"form_id": "frm_qr7k1x",
"payload": {
"email": "ada@example.com",
"message": "Hello there"
}
}
}
type— event name;submission.createdtoday,webhook.testfor test pings.id— the delivery id. Retries of the same delivery reuse this id but carry a freshcreated_at, so deduplicate onidalone.data— event-specific; for submissions: the stored payload plus its ids.
Requests also carry metadata headers:
| Header | Value |
|---|---|
x-formflow-event | Same as type. |
x-formflow-delivery | Same as id. |
x-formflow-attempt | 1–5. |
x-formflow-signature | sha256=<hex> — see below. |
user-agent | FormFlow-Webhooks/1 |
Verifying the signature
The signature is an HMAC-SHA256 of the raw request body, keyed with your
webhook secret, hex-encoded and prefixed with sha256=. Verify it before trusting
the payload — and compute it over the bytes you received, not a re-serialization:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
Respond with any 2xx status within a reasonable time to acknowledge the
delivery. Anything else — including network errors and timeouts — counts as a
failure and schedules a retry.
Retries
Failed deliveries are retried on a fixed backoff schedule:
1 s → 10 s → 1 min → 10 min → 1 h
5 attempts maximum (the initial attempt plus four retries). After that the
delivery is marked failed and left in the log. Every attempt is recorded —
inspect them with GET /v1/webhooks/:id/deliveries or in the console, and replay
any delivery manually with POST /v1/deliveries/:id/replay.
Because retries re-send the same envelope id, an idempotent consumer that keys
on id processes each event exactly once regardless of how many attempts it took.
Testing
Fire a synchronous test ping at your endpoint without creating a submission:
curl -X POST https://api.formflow.cc/v1/webhooks/wh_2m9x4c/test \
-H "Authorization: Bearer ff_mgmt_..."
The ping is signed exactly like a real delivery, with type: "webhook.test" and
data: { "form_id": "frm_…", "sample": true }. The response tells you what your
endpoint answered:
{ "status_code": 200, "ok": true }
Managing webhooks
| Method & path | What it does |
|---|---|
POST /v1/forms/:id/webhooks | Create. Body { "url": "https://…" }. Returns the secret (once). |
GET /v1/forms/:id/webhooks | List a form’s webhooks. |
PATCH /v1/webhooks/:id | Change url, toggle enabled, or set rotate_secret: true (new secret returned once). |
DELETE /v1/webhooks/:id | Remove the webhook. |
GET /v1/webhooks/:id/deliveries | Last 50 deliveries with status, attempt count and errors. |
POST /v1/deliveries/:id/replay | Re-send one delivery immediately, ignoring the schedule. |