Verify webhook signatures
This page is the code companion to Webhooks, which covers endpoint setup, event types, retries and key rotation.
How does Flownally sign webhook deliveries?
Each delivery has three headers:
webhook-id: the event ID. It equals the payloadidand stays the same on every retry.webhook-timestamp: the Unix time of this attempt, in seconds. It changes on every retry.webhook-signature: one or more signatures in the formv1,<base64>, separated by spaces.
To compute a v1 signature:
- Join the headers and the body with periods:
{webhook-id}.{webhook-timestamp}.{raw body}. - Remove the
whsec_prefix from the signing key and base64-decode the rest. The decoded bytes are the HMAC key. - Compute an HMAC-SHA256 of the joined string and base64-encode it.
- Compare the result with each signature in the header in constant time. Accept the delivery if any signature matches.
The signing key is the plaintextKey value, in the form whsec_….
GET /webhooks/signing-keys returns it once, on the first call in a workspace, and POST /webhooks/signing-keys:rotate returns a new one once. Later calls return metadata only, so store the key in your secret manager when you receive it.
Verify signatures with the standardwebhooks library
The official libraries are standardwebhooks on npm and on PyPI, and standard-webhooks/standard-webhooks on Packagist. They check the timestamp and accept any matching signature, so you don't have to write those checks yourself.
npm install standardwebhooks
pip install standardwebhooks
composer require standard-webhooks/standard-webhooksPass the signing key exactly as Flownally returns it, including the whsec_ prefix.
verify returns the parsed JSON payload. It throws (in Python, raises) WebhookVerificationError, or WebhookVerificationException in PHP, when:
- a header is missing,
- no signature matches, or
webhook-timestampis more than 5 minutes from the current time.
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const webhook = new Webhook(process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);
// Register this route before app.use(express.json()) so the body stays raw.
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.sendStatus(401);
}
await enqueue(event); // Your queue. Process the event after you respond.
res.sendStatus(204);
});Read the raw request body before you parse JSON
The signature covers the exact bytes Flownally sent. If a framework parses the JSON and you serialize it again, whitespace and key order can change, and verification fails.
Read the raw body → Verify it → Parse it
- Express. Add
express.raw({ type: "application/json" })to the webhook route and register it before any globalexpress.json()middleware.req.bodyis then aBuffer. - Next.js route handlers. Read
await request.text()and don't callrequest.json()first. The body can be read only once. - Next.js Pages Router API routes. Turn off the built-in parser with
export const config = { api: { bodyParser: false } }and read the request stream. - Flask, Django and FastAPI. Use
request.get_data()in Flask,request.bodyin Django, andawait request.body()in FastAPI. - PHP, Laravel and Symfony. Use
file_get_contents('php://input')in plain PHP, and$request->getContent()in Laravel and Symfony.
Next.js route handler:
// app/flownally/webhooks/route.js
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);
export async function POST(request) {
const rawBody = await request.text();
let event;
try {
event = webhook.verify(rawBody, Object.fromEntries(request.headers));
} catch {
return new Response(null, { status: 401 });
}
await enqueue(event);
return new Response(null, { status: 204 });
}Verify the webhook-signature header by hand
Use this when you can't add a dependency. Each version uses only the standard library, such as node:crypto in Node.js. The PHP version compares with hash_equals, which runs in constant time, and needs PHP 8.0 or later.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyFlownallyWebhook(rawBody, headers, signingKey) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) {
throw new Error("Missing webhook headers");
}
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) {
throw new Error("Stale or invalid webhook-timestamp");
}
const key = Buffer.from(signingKey.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
const valid = signatureHeader.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const received = Buffer.from(signature, "base64");
return received.length === expected.length && timingSafeEqual(received, expected);
});
if (!valid) {
throw new Error("Invalid webhook-signature");
}
return JSON.parse(rawBody.toString("utf8"));
}Call it with the raw body and the request headers, keyed by lowercase name. Node.js lowercases incoming header names, so req.headers["webhook-id"] works in Express. Flask's request.headers matches names in any case. In PHP, lowercase them with array_change_key_case(getallheaders()).
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = verifyFlownallyWebhook(req.body, req.headers, process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);
} catch {
return res.sendStatus(401);
}
await enqueue(event);
res.sendStatus(204);
});Reject stale webhook-timestamp values
A valid signature proves that Flownally sent the request, not that it was sent just now. Without a time check, an attacker can replay a captured request later.
Reject deliveries whose webhook-timestamp is more than 5 minutes before or after your server's current time.
The check doesn't reject legitimate retries. Flownally generates webhook-timestamp and webhook-signature again for each attempt.
The standardwebhooks libraries apply a 5-minute tolerance in both directions. The by-hand examples above do the same.
Keep your server clock in sync
Keep your server clock synchronized, for example with NTP, or valid deliveries fail this check.
Accept multiple signatures during signing key rotation
A receiver that expects exactly one signature rejects valid deliveries during a rotation.
After POST /webhooks/signing-keys:rotate, the old key is expiring for 24 hours, and both keys sign each delivery. During that overlap, webhook-signature contains two signatures separated by a space, for example v1,<signature with one key> v1,<signature with the other key>.
Your receiver needs only one of the keys. Split the header on spaces and accept the delivery if any signature matches.
Don't assume the header holds a single value, and don't depend on the order. The libraries handle this for you.
Deploy the new key to your receiver before the 24 hours end. See Rotate the signing key for the full flow.
Deduplicate deliveries on webhook-id
Delivery is at least once, so the same event can arrive more than once. If you process both copies, your system acts twice, for example by sending a customer a duplicate message.
The webhook-id header equals the payload id and stays the same on every retry, and so does the body. Use webhook-id as your idempotency key.
- Verify the signature first, so forged requests can't write to your dedupe store.
- Record the
webhook-idwith a unique constraint, in the same transaction that stores or enqueues the event. - If the ID already exists, return a
2xxstatus and skip the event.
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.sendStatus(401);
}
// Inserts the event and returns false when its id is already stored.
const isNew = await storeEventOnce(event.id, event);
if (isNew) await enqueue(event.id);
res.sendStatus(204);
});Deliveries can also arrive out of order, even within one conversation. For message.updated, keep the snapshot with the highest message.revision.
Respond with 2xx fast and process events asynchronously
Each delivery attempt times out after 15 seconds, and Flownally retries timeouts. Any 2xx status acknowledges the event.
Verify → Store or enqueue → Respond → Process in a worker
Do slow work, such as calling other APIs or a language model, from a background worker.
Choose the status code by what you want Flownally to do:
- Return
2xxonce the event is stored. Do this for duplicates and event types you ignore, too. - Return
401for a missing or invalid signature. Any4xxother than408and429stops retries for that event. - Return
5xxwhen your own storage or queue fails. Flownally retries timeouts, connection errors,5xx,408and429responses.
Redirects aren't followed, so point the webhook URL at the final address. See Retries for the schedule.
Test your receiver with POST /webhooks:verify
POST /webhooks:verify sends a signed webhook.test event to the saved webhook URL. To test another address, pass it as url in the body.
curl -X POST "https://api.flownally.com/v1/webhooks:verify" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN"The test event uses the same headers and signature as real deliveries. Its body carries no business data:
{
"id": "whevt_…",
"type": "webhook.test",
"timestamp": "2026-04-30T09:20:00Z",
"data": { "test": true }
}Return a 2xx status for it. The response reports delivered, the statusCode your endpoint returned, and an error when delivery failed.
Flownally delivers events only while the webhook is enabled and verified, and changing url clears verified.
FAQ
Why does every signature check fail?
The body you verify is usually not the raw body. Check that no JSON middleware runs before your webhook route and that you pass the bytes you received, not a re-serialized object. Then check that you verify with the current signing key.
Why did verification start failing after a key rotation?
The old key expires 24 hours after rotation. Deploy the new plaintextKey from the rotation response to every instance of your receiver. GET /webhooks/signing-keys lists the active key and any expiring key with its expiresAt, plus last4 to tell them apart.
Why does my by-hand check fail when the library passes?
Base64-decode the part of the key after whsec_ and use the decoded bytes as the HMAC key. Using the whsec_… string itself as the key produces a different signature. Also split webhook-signature on spaces before you compare.
How do I see what my endpoint returned?
Call GET /webhooks/attempts with status=failed. Each attempt shows responseStatus, responseBody, errorText, and signedWithVersions, the signing key versions used for that attempt. Compare them with version in GET /webhooks/signing-keys. Attempts are kept for 7 days.
Will a retried delivery fail the timestamp check?
No. Flownally generates webhook-timestamp and webhook-signature again for each attempt, so a retry carries a fresh timestamp. Keep your server clock synchronized so valid deliveries pass the 5-minute tolerance.