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
consttoleranceSeconds:300
toleranceSeconds=300;
exportfunctionsignatureHolds(
body: Uint8Array<ArrayBufferLike>
body:Uint8Array,
header: string |null
header:string|null,
secret: string
secret:string):boolean {
if (!
header: string |null
header) returnfalse;
const
constfields:string[][]
fields=
header: string
header.split(",").map((
field: string
field) =>
field: string
field.trim().split("="));
const
consttimestamp:number
timestamp=Number(
constfields:string[][]
fields.find(([
key: string
key]) =>
key: string
key==="t")?.[1]);
const
constsignatures:string[]
signatures=
constfields:string[][]
fields.filter(([
key: string
key]) =>
key: string
key==="v1").map(([,
value: string
value]) =>
value: string
value??"");
if (!Number.isInteger(
consttimestamp:number
timestamp)) returnfalse;
if (Math.abs(Date.now() /1000-
consttimestamp:number
timestamp) >
consttoleranceSeconds:300
toleranceSeconds) returnfalse;
const
constexpected:Buffer<ArrayBufferLike>
expected=new Bun.CryptoHasher("sha256",
secret: string
secret).update(`${
consttimestamp:number
timestamp}.`).update(
body: Uint8Array<ArrayBufferLike>
body).digest();
return
constsignatures:string[]
signatures.some((
signature: string
signature) => {
const
constgiven:Buffer<ArrayBuffer>
given= Buffer.from(
signature: string
signature, "hex");
return
constgiven:Buffer<ArrayBuffer>
given.
length: number
The length of the array.
length===
constexpected:Buffer<ArrayBufferLike>
expected.
length: number
The length of the array.
length&&timingSafeEqual(
constgiven:Buffer<ArrayBuffer>
given,
constexpected: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:
importtype { Requires } from"@tetsujs/core";
import { hook, httpError } from"@tetsujs/core";
exportfunctionsignedWebhook(
secret: string |undefined
secret:string|undefined) {
if (!
secret: string |undefined
secret) thrownewError("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.
throwhttpError(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.
: 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.
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:
importtype { Database } from"bun:sqlite";
exportfunctioneventInbox(
db: Database
db:Database) {
db: Database
db.run(`create table if not exists webhook_events (
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
constpending:Statement<{
id:string;
type:string;
payload:string;
}, []>
pending=
constdb: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",
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.