Skip to main content

Develop webhooks locally

Webhook endpoints must be public HTTPS URLs, so Formfeed cannot reach the server on your laptop. formfeed listen closes that gap: it forwards the workspace's webhook events to a local URL, signed exactly like a real delivery, so the handler you write locally is the handler you deploy. No tunnel, no temporary endpoint to register and remember to delete.

$ formfeed listen --forward-to http://localhost:3000/webhooks
Ready. Forwarding test-environment events of workspace "Fennlor" to http://localhost:3000/webhooks
Webhook signing secret: whsec_3f9c… (kept for this API key across listen sessions)

12:04:11 render.completed rnd_8Hk2… → 200 (41 ms) evt_5b0c…
12:04:15 render.failed rnd_Q1a9… → 500 (12 ms) evt_91d2…
12:05:15 render.failed rnd_Q1a9… → 204 (9 ms) attempt 2 evt_91d2…

You need the CLI (Node.js 22 or later) and a key of the workspace with the webhook:listen scope, which formfeed login gives editors, admins and owners.

How it works​

A listen session is a webhook endpoint of the workspace whose address is a relay in the Formfeed gateway. Events are matched, signed, retried and logged exactly as for a registered endpoint; only the last step differs.

  • When: at the same moment as for every other endpoint of the workspace. A local server sees render.completed about one to two seconds after the render ends.
  • From where: the request that reaches localhost comes from the CLI process on the same machine. Nothing on the internet connects to your laptop; the only connection is the WebSocket the CLI opened to the gateway, which works behind NAT, VPNs and corporate proxies that allow HTTPS.
  • What arrives: the byte-identical body and the headers Content-Type, User-Agent, Webhook-Id, Webhook-Timestamp, Webhook-Attempt and Webhook-Signature, as described in Payload. The signature uses the session's secret instead of an endpoint's.
  • What travels back: only your server's status code. The render-worker treats it like the answer of a real endpoint: 2xx is delivered, anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, and the delivery with its status is listed on the Webhooks page of the app. A local server that cannot be reached, or does not answer within 8 seconds, is recorded as 502.
  • How long a session lives: as long as the CLI stays connected. The relay renews the session every minute while the WebSocket is open; when the CLI stops or its connection drops, the session ends.

Set up a receiver​

Start with a handler that verifies the signature. It reads the secret from an environment variable, so the same code runs in production with the endpoint's secret.

server.ts
import express from 'express';
import { verifyWebhookSignature } from '@formfeed/sdk';

const app = express();

// express.text keeps the body as the exact string that was signed
app.post(
'/webhooks',
express.text({ type: 'application/json' }),
async (req, res) => {
const ok = await verifyWebhookSignature(
process.env.FORMFEED_WEBHOOK_SECRET!,
req.get('webhook-signature'),
req.body,
);
if (!ok) return res.status(400).send('invalid signature');

const event = JSON.parse(req.body);
console.log(event.type, event.data.id);
res.sendStatus(204);
},
);

app.listen(3000);

Then, in a second terminal:

  1. Log in once with formfeed login (or set FORMFEED_API_KEY to a key with the webhook:listen scope).
  2. Start the session: formfeed listen --forward-to http://localhost:3000/webhooks.
  3. Copy the secret it prints into FORMFEED_WEBHOOK_SECRET and restart your server. The secret belongs to your API key, so the next formfeed listen with the same key signs with the same secret and your .env stays valid.
  4. Make a render with a test key, or run formfeed trigger, and watch the delivery arrive.

Each line shows the time, the event, the render or job (- for an event without one, such as a quota event), your server's answer (→ failed: with the reason when it could not be reached or did not answer in time), the attempt from the second one on, and the event id. Ctrl+C ends the session; from then on, deliveries to it stop at once.

If the connection to the gateway drops, the CLI reconnects on its own with a new session after a pause of 1 to 30 seconds and prints "Reconnected with a new session; the signing secret is unchanged." When it cannot get a connection back after several attempts, it stops with an error. Events that were matched to the old session while the connection was down are not forwarded: their ids are in the delivery log of the app, and formfeed webhooks resend sends them to the new session. A session ended in the app stops the command.

The secret is taken over from the key's previous session, however long ago it ended: the latest session of every key is kept for that. Only a key that never listened gets a new secret, and the CLI prints it.

Options​

OptionDefaultEffect
--forward-to <url>http://localhost:3000/webhooksThe local URL that receives the events, http:// or https://
--events <types>every type but the storage onesComma-separated event types, for example render.completed,job.failed. The default takes the render, job and quota events; the opt-in storage.delivered and storage.failed are forwarded only when named
--liveoffAlso forward events of live renders. Without it, only renders made with test keys (ff_test_…) are forwarded. Needs the webhook:manage scope as well, or starting the session answers 403 forbidden
--print-secretoffStart a session, print only its signing secret and end the session again, for .env files: echo "FORMFEED_WEBHOOK_SECRET=$(formfeed listen --print-secret)" >> .env
--skip-verifyoffAccept a self-signed certificate when the local URL is https://
--jsonoffGlobal flag: one JSON line per delivery (and one when the session is ready, with its secret) instead of the table
--print-secret ends a running session

Starting a session ends the previous session of the same key, and --print-secret starts one. Run it before formfeed listen, or in a terminal that uses another key, or the running formfeed listen stops within a minute.

quota.warning and quota.exceeded belong to no environment and are forwarded whenever their type is selected, which it is by default. A render that carries its own webhook_url still sends to that URL; the session receives the same event as well, signed with the session secret.

Every command and flag is listed in the CLI reference.

Send an event again​

While a session runs, send any stored event to it again, whether it was delivered or not:

formfeed webhooks resend evt_5b0c8e6f2d4a4c3e9b1a7f6e5d4c3b2a

The event id is at the end of every line formfeed listen prints, in the Webhook-Id header of a delivery, and in the delivery log of the app. Without --to, the event goes to the session of the formfeed listen running with the same API key on this machine. --to <id> names a listen session or a registered endpoint instead; an endpoint needs webhook:manage. The resent delivery is a new one with a fresh signature and Webhook-Attempt: 1, and the body is the stored event unchanged, including its download_url, which may have expired by now.

Through the API it is POST /webhooks/events/{id}/resend with the endpoint_id of a session or a registered endpoint; the SDKs have client.webhooks.resend(eventId, endpointId) and client.webhooks.resend(event_id, endpoint_id). See Send an event again for the answers.

Trigger a real event​

formfeed trigger render.completed --template invoice --data templates/invoice/data/default.json

makes a real asynchronous render of the template, so the event your handler receives is genuine rather than a fixture: the render exists, download_url works and GET /renders/{id} returns it. The render carries "meta": { "source": "formfeed trigger" }. Without --data, the CLI uses the template's default data set in the project, or else the sample data of its published version (of its latest version when none is published). If the data does not fit, the event is render.failed.

Use a test key: test renders cost no units, and their events reach a session without --live. With a live key the CLI warns that the render is metered. render.completed is the only event trigger makes; send any other stored event again with formfeed webhooks resend.

Limits​

LimitValue
Sessions per workspace3 at a time; a fourth answers 409 listen_session_limit
Sessions per key or member1: starting a new session ends your previous one at once, and the formfeed listen that held it stops within a minute, saying that the session was ended
Deliveries per session20 per second; more are answered 429 and retried on the usual schedule
Time for your local server8 seconds; after that the delivery counts as failed and is retried
CostNone; sessions are not metered

When the session has ended, deliveries to it stop at once without retries, and resending to it answers 410 listen_session_ended.

A failing local server never disables anything: the rule that disables an endpoint after five exhausted events in a row does not apply to sessions. Ended sessions and their deliveries are deleted after a day, except the latest session of each key, which holds its signing secret.

Security​

  • Who may listen: keys with the webhook:listen scope. New API keys do not have it by default; formfeed login gives it to editors, admins and owners. Live events additionally need webhook:manage, which formfeed login gives only to admins and owners, so a developer does not see customers' documents by accident.
  • What a session reveals: the same events and signed download links members already see in the delivery log of the app.
  • Signed end to end: the relay verifies every delivery with the session secret before it forwards anything, and your handler verifies it again. A guessed session id is useless.
  • Nothing stored: the relay holds only the open WebSocket. Payloads pass through Cloudflare's EU jurisdiction and are never written to disk there; the delivery log in the database is the same as for any endpoint. See Data residency.
  • Not a tunnel: listen forwards Formfeed events to one local URL. It does not expose your server, accept other traffic or pass back anything but the status code.
  • Ending sessions: the Webhooks page of the app lists the running sessions under CLI sessions, where owners and admins can end one with End session. Through the API, DELETE /webhooks/listen/{id} ends a session of the workspace with any key that has webhook:listen.

Without the CLI​

formfeed listen is a client of POST /webhooks/listen (scope webhook:listen). The body takes events (a list of event types, the same default as the CLI) and live (true to include live renders, which needs webhook:manage). The answer is 201 with the session's id, its secret, its events and environments, expires_at, and a websocket_url that is valid for 60 seconds and needs no key.

Over that WebSocket, the relay sends JSON messages: { "type": "ready", "session": "…" } once the connection stands, { "type": "delivery", "id": "…", "headers": { … }, "body": "…" } for every delivery, and { "type": "ended", "reason": "…" } when the session was ended elsewhere. Answer each delivery within 9 seconds with { "type": "result", "id": "…", "status": 204 }, the status your server gave, or 0 when it could not be reached. The relay closes the socket with code 4000 when the session ended and 4001 when another connection took it over. DELETE /webhooks/listen/{id} ends the session. The SDKs start and end sessions with client.webhooks.listen.start() and .end(id); the WebSocket is up to your code.

In production​

Register a real endpoint as described in Webhooks, put its secret into FORMFEED_WEBHOOK_SECRET, and deploy the same handler. Use the endpoint's environments to keep test events away from production.