Verify webhook deliveries
Check that a webhook delivery came from Koil before trusting it, and handle retries without duplicates.
Every webhook delivery is signed with the destination's secret: the credentials.secret you set when creating the destination. Verify the signature before you act on an event. Anyone can send a request to your endpoint.
The headers
| Header | Value |
|---|---|
x-koil-signature | v0= then the hex HMAC-SHA256 of the raw request body, keyed with the destination secret. While a secret is being rotated it carries one signature per valid secret, comma-separated: v0=<current>,<previous>. |
x-koil-event-id | The event id, for dedupe. |
x-koil-topic | The event topic. |
x-koil-timestamp | When the delivery was signed. |
Verify in Node.js
Compute the HMAC over the raw body, exactly as received — parse the JSON only after the signature checks out, since re-serializing changes the bytes. Compare in constant time, and accept the delivery if any listed signature matches.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyKoilSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret: string,
): boolean {
if (!signatureHeader?.startsWith("v0=")) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest();
return signatureHeader
.slice("v0=".length)
.split(",")
.some((candidate) => {
const signature = Buffer.from(candidate.trim(), "hex");
return (
signature.length === expected.length &&
timingSafeEqual(signature, expected)
);
});
}
With Express, keep the body raw on the webhook route, and pass the handler your own enqueue:
import express from "express";
export function createWebhookApp(
enqueue: (event: unknown) => Promise<void>,
): express.Express {
const app = express();
app.post(
"/koil/events",
express.raw({ type: "application/json" }),
async (req, res) => {
const valid = verifyKoilSignature(
req.body,
req.header("x-koil-signature"),
process.env.KOIL_WEBHOOK_SECRET ?? "",
);
if (!valid) {
res.status(400).send("bad signature");
return;
}
const event = JSON.parse(req.body.toString("utf8"));
// Dedupe on the event id, store or enqueue the event, then acknowledge.
await enqueue(event);
res.sendStatus(204);
},
);
return app;
}Retries and duplicates
A delivery that does not get a 2xx response is retried, and a delivery can be replayed from the Events dashboard, so the same event can arrive more than once. Store each event id you have processed and skip repeats. Return 2xx only once the event is durably stored or enqueued — process it afterwards, so a slow handler never causes a retry.
Rotating the secret
POST /v1/event-destinations/{destinationId}/rotate-secret starts a rotation. Until previousSecretInvalidAt (or the default window) passes, deliveries carry a signature for both the new and the previous secret, so deploy the new secret to your receiver during that window and the check above keeps passing throughout.