Skip to main content

Webhooks

Webhooks tell your application when something happened: a render finished, a batch completed, the quota is running out. Formfeed sends an HTTPS POST with a JSON event to every endpoint that subscribed to that event type.

There are two ways to receive them:

  • A registered endpoint of the workspace receives every event of the types it subscribed to. This is what a production integration uses.
  • A per-render URL: a render or batch request names its own webhook_url, which receives that request's events only.
Building the receiver on your machine?

formfeed listen --forward-to http://localhost:3000/webhooks forwards the workspace's events to a local server, signed like real deliveries. See Develop webhooks locally.

Register an endpoint​

In the app, owners and admins add endpoints under Webhooks with Add endpoint. Through the API it takes a key with the webhook:manage scope, which is not among a new key's default scopes:

curl https://api-eu.formfeed.dev/v1/webhooks \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://fennlor.example/hooks/formfeed", "events": ["render.completed", "render.failed", "job.completed", "job.failed"] }'
FieldTypeDefaultMeaning
urlstring, required–Where events are posted: a public https:// address, at most 2,000 characters. Private, internal and metadata addresses are refused with 400.
eventsarrayrender.completed, render.failed, job.completed, job.failedThe event types the endpoint receives. quota.* and the opt-in storage.* types arrive only when listed.
environmentsarray["live", "test"]Whose renders the endpoint hears about, see Test and live events.
descriptionstringnullYour own note, at most 200 characters.

The answer is 201 with the endpoint: its id (a UUID, which the other calls take), its settings, enabled: true and its signing secret, a string that starts with whsec_. The secret is shown only once: store it as FORMFEED_WEBHOOK_SECRET. A Location header names the endpoint's absolute URL.

The URL must be public HTTPS. Redirects are not followed: a 3xx answer counts as a failed delivery.

How many endpoints an organisation can have is set by its plan: one on Free, five on Starter, any number from Pro, counted over all its workspaces, disabled ones included. A switched-on trigger of a no-code tool such as Zapier, Make or n8n is an endpoint as well; a formfeed listen session is not. One more answers 403 forbidden with feature: "webhooks" and the limit: delete an endpoint or change the plan.

Manage endpoints with these calls, all with webhook:manage:

CallDoes
GET /webhooksLists the workspace's endpoints as { "data": [ … ] }, with consecutive_failures but without secrets. Running formfeed listen sessions are not listed.
POST /webhooksCreates an endpoint, as above.
PUT /webhooks/{id}Changes any of url, events, environments, description and enabled; fields left out stay as they are. "enabled": true also resets the failure count.
DELETE /webhooks/{id}Deletes the endpoint (204).
POST /webhooks/{id}/testSends a webhook.test event to this one endpoint (202, { "queued": true }), whatever its events and environments. A disabled endpoint receives nothing, so its test answers 409 conflict: switch it on first. The delivery log shows the outcome.

The SDKs have the same calls as client.webhooks.list(), create(), update(), delete() and test(). Send test on the Webhooks page of the app does what POST /webhooks/{id}/test does.

Test and live events​

environments chooses whose renders an endpoint hears about: live for renders made with live keys, test for renders made with test keys. Both are the default. A staging receiver that should never see production documents takes ["test"], a production receiver that should ignore your tests ["live"]:

{
"url": "https://staging.fennlor.example/hooks/formfeed",
"events": ["render.completed"],
"environments": ["test"]
}

Change it later with PUT /webhooks/{id}. quota.warning and quota.exceeded belong to no environment and reach every endpoint that subscribed to them. storage.delivered and storage.failed belong to the environment of the render that was copied.

Events​

EventSent whendataSubscribed by default
render.completedA render succeeded: sync, async, or one item of a batchThe render, with download_url and your metayes
render.failedA render failed for goodThe render, with erroryes
job.completedA batch finished and at least one item succeeded (its status is completed or partially_failed); when the batch asked for a zip, once the zip is readyThe job, with zip_url and the renders in itemsyes
job.failedA batch finished and every item failedThe jobyes
quota.warning80 % of the period's included units are used, once per billing periodorg_id, period_start, usage, see Quota eventsno
quota.exceededEvery included unit of the period is used, once per billing periodorg_id, period_start, usageno
storage.deliveredOpt-in. A document's copy arrived in the workspace's own bucketThe copy: bucket, key, url, render_id, sha256, etag, …no
storage.failedOpt-in. A copy to the workspace's own bucket failed for goodThe copy, with reason and the provider's errorno
webhook.testYou pressed Send test or called POST /webhooks/{id}/testendpoint_id, messagealways, to the tested endpoint only

An endpoint created without events receives the four render and job events. Add quota.warning and quota.exceeded to its list to receive those. The two storage events are opt-in: an endpoint receives them only when its events list names them, which no endpoint does by default, existing ones included. A render's own webhook_url receives neither the quota nor the storage events.

A single render with its own webhook_url sends only render.* events, never a job.* event; job.* events belong to batches.

New event types can appear at any time. Ignore the ones you do not handle, and answer 2xx anyway.

Quota events​

Quota events concern the whole organisation, not one workspace: they reach the subscribed endpoints of every workspace of the organisation, and their workspace_id is null. usage is the state of the billing period when the event was created:

{
"id": "evt_2c7e0f9a4b1d4e6f8a3c5b7d9e1f2a4c",
"type": "quota.warning",
"created_at": "2026-09-24T08:12:40Z",
"workspace_id": null,
"data": {
"org_id": "5f2b9c1e-…",
"period_start": "2026-09-07",
"usage": {
"period_start": "2026-09-07",
"period_end": "2026-10-07",
"included_units": 15000,
"used_units": 12004,
"overage_used": 0,
"overage_balance": 0,
"pack_units_used": 0,
"plan_id": "pro",
"price_eur_month": 59,
"overage_eur_per_1000": 5,
"auto_top_up": false,
"hard_cap_units": 50400
}
}
}

hard_cap_units is where a paid plan stops rendering (a prepaid pack raises it), null on a plan without overage. See When units run out. The owners and admins of the organisation, and its billing email address, get the same two notices by email.

Payload​

POST /hooks/formfeed HTTP/1.1
Content-Type: application/json
User-Agent: Formfeed-Webhooks/1.0
Webhook-Id: evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a
Webhook-Timestamp: 1757584800
Webhook-Attempt: 1
Webhook-Signature: t=1757584800,v1=5f1c2a…

{
"id": "evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a",
"type": "render.completed",
"created_at": "2026-09-11T10:00:00Z",
"workspace_id": "3a6f…",
"data": {
"id": "rnd_7da09ab6cd9946b798c187c2a9350852",
"status": "succeeded",
"download_url": "https://cdn.formfeed.dev/o/eu/…?exp=…&sig=…",
"page_count": 2,
"meta": { "order_id": "8812" },
…
}
}
HeaderValue
Webhook-IdThe event id, evt_…. It stays the same across retries and redeliveries of the event, and every endpoint that receives the event gets the same id.
Webhook-TimestampUnix seconds when this attempt was sent. The same value is t in the signature.
Webhook-AttemptThe delivery attempt, starting at 1.
Webhook-Signaturet=<unix seconds>,v1=<hex>, see Verify the signature. Missing on a per-render URL without a webhook_secret.

The body is the event: id, type, created_at (when the event was created), workspace_id (null for quota events) and data.

For render.* events, data is the render exactly as GET /renders/{id} returns it, with rnd_… and tpl_… ids; for job.* events, the batch as GET /jobs/{id} returns it, with zip_url and the renders in items; for storage.* events, the copy with its status, bucket and key. The download_url and zip_url in an event are signed when the event is created and work for seven days or until the output expires, whichever comes first (an output with "access": "public" has its public link instead, which lives as long as the file). A delivery retried or redelivered later carries the same URLs; fetch the render for a fresh one.

Verify the signature​

The Webhook-Signature header is t=<unix seconds>,v1=<hex>:

  • t is the moment the attempt was sent. Every attempt, retries included, is signed again with a new t.
  • v1 is the HMAC-SHA256 of the string t + "." + rawBody, written as 64 lowercase hex characters.
  • The key is your secret exactly as you received it, as UTF-8 bytes: the whole whsec_… string of an endpoint or a formfeed listen session, or the webhook_secret you sent with a per-render URL. Do not strip the whsec_ prefix or decode the rest.

The header names are the ones of the Standard Webhooks specification, but the signature is not: a library written for that specification rejects every Formfeed delivery. Use the SDKs or the code below. Always verify before you trust an event:

  1. Compute the HMAC over the raw request body, exactly as received. Parsing the JSON and serialising it again changes the bytes and breaks the signature.
  2. Compare in constant time.
  3. Reject a t more than five minutes away from your clock, which stops replays of captured requests. The SDKs reject it in both directions, so keep the server's clock synchronised.
import express from 'express';
import { parseWebhookEvent } from '@formfeed/sdk';

const app = express();

// express.text keeps the body as the exact string that was signed
app.post(
'/hooks/formfeed',
express.text({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = await parseWebhookEvent(
process.env.FORMFEED_WEBHOOK_SECRET!,
req.get('webhook-signature'),
req.body,
);
} catch {
return res.status(400).send('invalid signature');
}

if (event.type === 'render.completed') {
await queue.add('store-document', event.data); // do the work later, answer now
}
res.sendStatus(204);
},
);

parseWebhookEvent throws when the signature does not match and returns the parsed event otherwise. verifyWebhookSignature(secret, header, rawBody) resolves to true or false instead. Both are async, take an optional fourth argument { toleranceSeconds } (default 300) and use Web Crypto, so they work in Node, Deno, Bun and Cloudflare Workers.

A complete receiver that listens, verifies in constant time and answers 401 when the check fails is written out for seven languages in the guides: Node.js, Python, PHP, Go, C#, Java and Ruby.

Build a reliable handler​

  • Answer fast. Return 2xx within 10 seconds, or the delivery counts as failed. Put slow work (downloading the file, sending an email) on a queue and acknowledge first.
  • Expect duplicates. A delivery can arrive more than once, for example when your 2xx got lost, or when someone sends the event again. Deduplicate on the event id.
  • Expect any order. Retries can overtake newer events. If order matters, read the current state with GET /renders/{id} instead of trusting the sequence of events.
  • Use meta. Put your own ids in the render's meta so the handler knows which record the document belongs to, without a lookup table.
  • Download promptly. The download_url in the event is signed and expires. If you need a fresh link later, call GET /renders/{id}.

Retries and disabling​

A delivery succeeds when your endpoint answers 2xx within 10 seconds. Anything else (another status, a redirect, a timeout, a connection error, an address that now resolves to a private network) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts in all, spread over about 14 hours and 40 minutes. Each attempt carries the same body and Webhook-Id, a new Webhook-Timestamp and signature, and the next Webhook-Attempt.

When the retries of five events in a row are exhausted, Formfeed switches the endpoint off: enabled becomes false, and the Webhooks page shows it as Turned off after failures. No email is sent about it, so watch the page, or enabled and consecutive_failures in GET /webhooks. A delivery that succeeds in between resets the count. While the endpoint is off, new events are not delivered to it. Switch it back on with its Enabled switch once the handler is fixed, or with PUT /webhooks/{id} and { "enabled": true }. Per-render URLs and formfeed listen sessions are never switched off.

Every delivery, with its status code, response time and error, is listed under Deliveries on the Webhooks page. There, owners and admins can Redeliver a delivery that has not succeeded, without waiting for the next retry.

Send an event again​

POST /webhooks/events/{id}/resend sends a stored event again as a new delivery, whether it was delivered or not, for example after you fixed a handler that answered 2xx but did the wrong thing:

curl https://api-eu.formfeed.dev/v1/webhooks/events/evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a/resend \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "endpoint_id": "8d7c2a4e-…" }'

endpoint_id is an enabled, registered endpoint of the workspace (scope webhook:manage) or a running formfeed listen session (scope webhook:listen). The event must have gone out to an endpoint, a session or a per-render URL of the workspace before, successfully or not: that delivery is where it is stored. The answer is 202 with the new delivery_id, the event_id, the event type and the endpoint_id; the delivery is signed afresh, starts at Webhook-Attempt: 1 and follows the normal retries. The body is the event as it was stored, so its download_url may have expired.

An unknown event or endpoint, or a switched-off endpoint, answers 404 not_found; a session that has ended answers 410 listen_session_ended. The SDKs call it client.webhooks.resend(eventId, endpointId), the CLI formfeed webhooks resend <evt_id> --to <endpoint id>.

Per-render webhook URLs​

A render or batch request can carry its own webhook_url, a public https:// address like an endpoint's. Its events go to that URL in addition to the endpoints registered for the workspace, whether the render runs synchronously or in the background:

  • A single render sends its render.completed or render.failed there.
  • A batch sends the render.* event of every item there, and the job.* event when the batch is finished.

Send a webhook_secret with it and those deliveries are signed with it, exactly like the ones of a registered endpoint: same Webhook-Signature header, same verification code, with your webhook_secret as the key. The secret is yours, is never returned by the API and is not shown in the app.

{
"template": "invoice-de",
"data": { "invoice": { "number": "2026-0042" } },
"mode": "async",
"webhook_url": "https://fennlor.example/hooks/one-render",
"webhook_secret": "a-long-random-string-you-keep"
}

webhook_secret takes between 16 and 200 characters, and only together with a webhook_url; anything else answers 400 invalid_request. Retries of the same delivery are signed with it too. The URL receives no quota.* or storage.* events, its deliveries follow the same retries, and its failures never switch anything off.

Without a secret the delivery is not signed

Leave webhook_secret out and the delivery carries no Webhook-Signature, so you cannot tell it from a forged request to the same address. Treat its body as a hint then: take the render id and fetch the render with GET /renders/{id} before you act.