FormFlow
EN
Start free
Docs/ API/ Webhooks

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.created today, webhook.test for test pings.
  • id — the delivery id. Retries of the same delivery reuse this id but carry a fresh created_at, so deduplicate on id alone.
  • data — event-specific; for submissions: the stored payload plus its ids.

Requests also carry metadata headers:

HeaderValue
x-formflow-eventSame as type.
x-formflow-deliverySame as id.
x-formflow-attempt15.
x-formflow-signaturesha256=<hex> — see below.
user-agentFormFlow-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 & pathWhat it does
POST /v1/forms/:id/webhooksCreate. Body { "url": "https://…" }. Returns the secret (once).
GET /v1/forms/:id/webhooksList a form’s webhooks.
PATCH /v1/webhooks/:idChange url, toggle enabled, or set rotate_secret: true (new secret returned once).
DELETE /v1/webhooks/:idRemove the webhook.
GET /v1/webhooks/:id/deliveriesLast 50 deliveries with status, attempt count and errors.
POST /v1/deliveries/:id/replayRe-send one delivery immediately, ignoring the schedule.
Last updated: June 10, 2026