Skip to content

Webhooks

This guide receives webhooks from a payment provider. It checks the signature over the bytes as they were sent, records each event once however often it is delivered, and answers before the work the event asks for is done.

A signature holds only over the exact bytes the sender signed: parsed and serialized again, the same JSON may come out with its keys in another order, and the signature no longer matches. rawBody: true on a route keeps the bytes in ctx.rawBody while the body is still parsed and validated into ctx.body. A beforeValidation hook runs between the two. See Request bodies.

The provider here signs in the style Stripe uses: a header t=1767225600,v1=5257a869… with a timestamp and a hex HMAC-SHA256 of the timestamp and the body joined by a dot. Other providers differ in the details; the shape of the check stays the same.

import { timingSafeEqual } from "node:crypto";
const
const toleranceSeconds: 300
toleranceSeconds
= 300;
export function signatureHolds(
body: Uint8Array<ArrayBufferLike>
body
: Uint8Array,
header: string | null
header
: string | null,
secret: string
secret
: string): boolean {
if (!
header: string | null
header
) return false;
const
const fields: string[][]
fields
=
header: string
header
.split(",").map((
field: string
field
) =>
field: string
field
.trim().split("="));
const
const timestamp: number
timestamp
= Number(
const fields: string[][]
fields
.find(([
key: string
key
]) =>
key: string
key
=== "t")?.[1]);
const
const signatures: string[]
signatures
=
const fields: string[][]
fields
.filter(([
key: string
key
]) =>
key: string
key
=== "v1").map(([,
value: string
value
]) =>
value: string
value
?? "");
if (!Number.isInteger(
const timestamp: number
timestamp
)) return false;
if (Math.abs(Date.now() / 1000 -
const timestamp: number
timestamp
) >
const toleranceSeconds: 300
toleranceSeconds
) return false;
const
const expected: Buffer<ArrayBufferLike>
expected
= new Bun.CryptoHasher("sha256",
secret: string
secret
).update(`${
const timestamp: number
timestamp
}.`).update(
body: Uint8Array<ArrayBufferLike>
body
).digest();
return
const signatures: string[]
signatures
.some((
signature: string
signature
) => {
const
const given: Buffer<ArrayBuffer>
given
= Buffer.from(
signature: string
signature
, "hex");
return
const given: Buffer<ArrayBuffer>
given
.
length: number

The length of the array.

length
===
const expected: Buffer<ArrayBufferLike>
expected
.
length: number

The length of the array.

length
&& timingSafeEqual(
const given: Buffer<ArrayBuffer>
given
,
const expected: Buffer<ArrayBufferLike>
expected
);
});
}
  • timingSafeEqual, not ===. A string comparison stops at the first difference, and its timing tells an attacker how much of a forged signature was right. timingSafeEqual throws on buffers of different lengths, so the length is checked first.
  • The timestamp is signed too. One older than five minutes is refused, so a recorded request cannot be replayed later.
  • Every v1 counts. A provider rotating its secret sends signatures made with both the old and the new one.

The hook that runs the check is built once from the secret:

import type { Requires } from "@tetsujs/core";
import { hook, httpError } from "@tetsujs/core";
export function signedWebhook(
secret: string | undefined
secret
: string | undefined) {
if (!
secret: string | undefined
secret
) throw new Error("signedWebhook: the secret is empty");
return hook.beforeValidation((
ctx: Requires<{
rawBody: Uint8Array;
}>
ctx
: Requires<{
rawBody: Uint8Array<ArrayBufferLike>
rawBody
: Uint8Array }>) => {
if (!signatureHolds(
ctx: Requires<{
rawBody: Uint8Array;
}>
ctx
.
rawBody: Uint8Array<ArrayBufferLike>
rawBody
,
ctx: Requires<{
rawBody: Uint8Array;
}>
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("x-signature"),
secret: string
secret
)) {
throw httpError(401, "BAD_SIGNATURE", "The signature does not match the body");
}
});
}

An empty secret — a variable nobody set — fails at startup, since anyone can sign with an empty key. The hook declares that it needs ctx.rawBody with Requires, so mounting it on a route without rawBody: true does not compile.

Checking in beforeValidation refuses an unsigned request with 401 before the schema sees it, so a stranger gets no 422 describing what the schema expects. A body that is not JSON is refused with 400 while it is parsed, before the hook runs.

import { controller, route } from "@tetsujs/core";
import { z } from "zod";
const PaymentEvent = z.object({
id: z.string(),
type: z.string(),
data: z.object({ object: z.record(z.string(), z.unknown()) }),
});
interface WebhooksDeps {
readonly
inbox: {
receive(id: string, type: string, payload: string): boolean;
}
inbox
: ReturnType<typeof eventInbox>;
readonly
secret: string | undefined
secret
: string | undefined;
}
export const webhooksController = controller("Webhooks", ({
inbox: {
receive(id: string, type: string, payload: string): boolean;
}
inbox
,
secret: string | undefined
secret
}: WebhooksDeps) => {
const signed = signedWebhook(
secret: string | undefined
secret
);
return {
payments: route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/webhooks/payments"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/webhooks/payments",
rawBody?: true | undefined

Keeps the bytes the body was read from, as ctx.rawBody, next to the body parsed from them — for a webhook, whose signature is over the bytes it was sent as, and whose payload is handled through a schema.

The bytes are there from beforeValidation on, so a hook can check the signature before the body is validated, and they are typed only on a route that asks — the hook that needs them says so with Requires<{ rawBody: Uint8Array }>. A json or text body only: a form is parsed natively and a stream is the raw body already.

Only the route that asks pays for it: its body is held twice, as bytes and as what was parsed from them.

rawBody
: true,
schema: { body: PaymentEvent },
hooks: { beforeValidation: [signed] },
docs?: RouteDocs | undefined

Documentation metadata for OpenAPI generation.

docs
: {
hidden?: boolean | undefined

Keeps the route out of the generated document.

For endpoints that exist but are nobody's business to call: an internal probe, an admin escape hatch, a route kept alive for one legacy client. The route is served exactly as before — this is a statement about the document, not about access, and a hidden route is as reachable as any other.

false says the opposite out loud: the route is in the document even when its handler was annotated to keep the routes it answers out of it — a package's handler serving files, say. Left out, the handler decides; written, the route does.

deprecated is the other half of the pair: an endpoint on its way out stays in the document and says so, an endpoint that was never public is simply absent.

hidden
: true },
handler: (ctx) => {
inbox: {
receive(id: string, type: string, payload: string): boolean;
}
inbox
.receive(ctx.
body: {
id: string;
type: string;
data: {
object: Record<string, unknown>;
};
}
body
.
id: string
id
, ctx.
body: {
id: string;
type: string;
data: {
object: Record<string, unknown>;
};
}
body
.
type: string
type
, new TextDecoder().decode(ctx.
rawBody: Uint8Array<ArrayBufferLike>
rawBody
));
},
}),
};
});

The handler returns nothing, so the answer is 204.

  • The schema accepts every event type. Providers add new types without notice. A schema that refused them would answer 422, and the sender would retry each one for days. Check the type where the event is handled.
  • The raw body is stored, not ctx.body: the Zod object strips fields it does not declare, and the raw text keeps everything the sender signed.
  • docs: { hidden: true } leaves the route out of the OpenAPI document.
  • The body limit is the application’s maxBodySize, 1 MiB by default. Set maxBodySize on this route if the provider’s events are larger.

Webhooks are delivered at least once. A sender that did not hear the answer in time sends the same event again. Every event carries an id, so a table with that id as its primary key turns a second delivery into a no-op:

import type { Database } from "bun:sqlite";
export function eventInbox(
db: Database
db
: Database) {
db: Database
db
.run(`create table if not exists webhook_events (
id text primary key,
type text not null,
payload text not null,
received_at integer not null,
processed_at integer
)`);
const
const insert: Statement<unknown, SQLQueryBindings[] | [null] | [string] | [number] | [bigint] | [false] | [true] | [Uint8Array<ArrayBufferLike>] | [Uint8ClampedArray<ArrayBufferLike>] | [Uint16Array<ArrayBufferLike>] | [Uint32Array<ArrayBufferLike>] | [Int8Array<ArrayBufferLike>] | [Int16Array<ArrayBufferLike>] | [Int32Array<ArrayBufferLike>] | ... 5 more ... | [...]>
insert
=
db: Database
db
.prepare(
"insert into webhook_events (id, type, payload, received_at) values (?, ?, ?, ?) on conflict (id) do nothing",
);
return {
receive: (id: string, type: string, payload: string) => boolean
receive
: (
id: string
id
: string,
type: string
type
: string,
payload: string
payload
: string) =>
const insert: Statement<unknown, SQLQueryBindings[] | [null] | [string] | [number] | [bigint] | [false] | [true] | [Uint8Array<ArrayBufferLike>] | [Uint8ClampedArray<ArrayBufferLike>] | [Uint16Array<ArrayBufferLike>] | [Uint32Array<ArrayBufferLike>] | [Int8Array<ArrayBufferLike>] | [Int16Array<ArrayBufferLike>] | [Int32Array<ArrayBufferLike>] | ... 5 more ... | [...]>
insert
.run(
id: string
id
,
type: string
type
,
payload: string
payload
, Date.now()).
changes: number

The number of rows changed by the last run or exec call.

changes
=== 1,
};
}

receive answers whether the event was new. A duplicate is still answered 204, so the sender stops sending it. The check and the write are one atomic statement, so two deliveries arriving at once cannot both be taken as new; a separate check followed by a write could let both through.

A sender waits a few seconds and then counts the delivery as failed. The work an event asks for — updating an order, sending an email — can take longer, and a failure half-way must not lose the event. The table above is already a queue: the handler records the event and answers, and a job processes what is still pending:

const
const pending: Statement<{
id: string;
type: string;
payload: string;
}, []>
pending
=
const db: Database
db
.query<{
id: string
id
: string;
type: string
type
: string;
payload: string
payload
: string }, []>(
"select id, type, payload from webhook_events where processed_at is null order by received_at limit 100",
);
const
const processed: Statement<unknown, SQLQueryBindings[] | [null] | [string] | [number] | [bigint] | [false] | [true] | [Uint8Array<ArrayBufferLike>] | [Uint8ClampedArray<ArrayBufferLike>] | [Uint16Array<ArrayBufferLike>] | [Uint32Array<ArrayBufferLike>] | [Int8Array<ArrayBufferLike>] | [Int16Array<ArrayBufferLike>] | [Int32Array<ArrayBufferLike>] | ... 5 more ... | [...]>
processed
=
const db: Database
db
.prepare("update webhook_events set processed_at = ? where id = ?");
export async function processEvents(): Promise<void> {
for (const
const event: {
id: string;
type: string;
payload: string;
}
event
of
const pending: Statement<{
id: string;
type: string;
payload: string;
}, []>
pending
.all()) {
await
const payments: {
apply(type: string, event: unknown): Promise<void>;
}
payments
.apply(
const event: {
id: string;
type: string;
payload: string;
}
event
.
type: string
type
, JSON.parse(
const event: {
id: string;
type: string;
payload: string;
}
event
.
payload: string
payload
));
const processed: Statement<unknown, SQLQueryBindings[] | [null] | [string] | [number] | [bigint] | [false] | [true] | [Uint8Array<ArrayBufferLike>] | [Uint8ClampedArray<ArrayBufferLike>] | [Uint16Array<ArrayBufferLike>] | [Uint32Array<ArrayBufferLike>] | [Int8Array<ArrayBufferLike>] | [Int16Array<ArrayBufferLike>] | [Int32Array<ArrayBufferLike>] | ... 5 more ... | [...]>
processed
.run(Date.now(),
const event: {
id: string;
type: string;
payload: string;
}
event
.
id: string
id
);
}
}

Run it every few seconds as in Background jobs. An event whose processing throws stays pending and is tried again on the next run. Count the attempts in the table and set aside an event past a limit, so one that can never succeed does not block the rest.

Starting the work after the response without waiting for it — in an afterResponse hook, or a promise the handler does not return — needs no table, but the work lives only in memory. The sender has been told the event arrived and will not send it again, so a restart or deploy half-way loses it for good. That is fine for a cache to warm; for anything the event is the only record of, keep the table.

A test signs the body the way the sender does and sends it to a served application, with the inbox on an in-memory database:

import { expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { createApp } from "@tetsujs/core";
import { serve } from "@tetsujs/core/testing";
import { eventInbox, webhooksController } from "./webhooks";
const
const secret: "test-secret"
secret
= "test-secret";
const
const inbox: {
receive: (id: string, type: string, payload: string) => boolean;
}
inbox
= eventInbox(new Database(":memory:"));
const
const request: RequestFn
request
= serve(createApp({ routes: webhooksController({
inbox: {
receive: (id: string, type: string, payload: string) => boolean;
}
inbox
,
secret: string | undefined
secret
}) }));
function signed(
body: string
body
: string,
at: number
at
= Math.floor(Date.now() / 1000)) {
const
const signature: string
signature
= new Bun.CryptoHasher("sha256",
const secret: "test-secret"
secret
).update(`${
at: number
at
}.${
body: string
body
}`).digest("hex");
return { "x-signature": `t=${
at: number
at
},v1=${
const signature: string
signature
}` };
}
const
const event: string
event
= JSON.stringify({
id: string
id
: "evt_1",
type: string
type
: "payment.succeeded",
data: {
object: {};
}
data
: {
object: {}
object
: {} } });
test("a signed event is accepted", async () => {
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/webhooks/payments", {
method?: string | undefined

A string to set request's method.

method
: "POST",
body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
:
const event: string
event
,
headers?: HeadersInit | undefined

A Headers object, an object literal, or an array of two-item arrays to set request's headers.

headers
: signed(
const event: string
event
) });
expect(
const res: Response
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(204);
});
test("an event signed an hour ago is refused", async () => {
const
const hourAgo: number
hourAgo
= Math.floor(Date.now() / 1000) - 3_600;
const
const res: Response
res
= await
const request: RequestFn
(path: string, init?: RequestInit) => Promise<Response>
request
("/webhooks/payments", {
method?: string | undefined

A string to set request's method.

method
: "POST",
body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
:
const event: string
event
,
headers?: HeadersInit | undefined

A Headers object, an object literal, or an array of two-item arrays to set request's headers.

headers
: signed(
const event: string
event
,
const hourAgo: number
hourAgo
) });
expect(
const res: Response
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
).toBe(401);
});

A test that sends the same event twice and counts the rows checks the inbox. More on testing is in Testing.