Receive webhooks
Verify the raw request, decode a typed event, and process each event exactly once. Examples for Next.js, Hono, Express, and Node.js.
A receiver checks that a request really came from the sender, then acts on it once. parseWebhook() from good-webhooks/verify does the checking. That import loads no database or worker code, so it fits in any Node.js service.
- Read the raw bodyBounded in size, as bytes. Not parsed JSON.
- Verify and parse
parseWebhook()checks the signature and returns a typed event. - Process onceRecord
event.idwith your change in one transaction. - Respond
2xxwhen done.400for bad requests,500to get a retry.
Add a webhook route
Each handler is complete. Put your event map in events.ts (shown below) and the endpoint's signing secret in WEBHOOK_SECRET.
import { parseWebhook, WebhookError } from 'good-webhooks/verify'
import { events } from '@/lib/events'
export async function POST(request: Request) {
// Reject oversized bodies before buffering. Your platform's body limit also applies.
if (Number(request.headers.get('content-length')) > 512 * 1024) {
return new Response('Webhook body is too large', { status: 413 })
}
try {
const event = await parseWebhook({
body: new Uint8Array(await request.arrayBuffer()),
headers: request.headers,
secret: process.env.WEBHOOK_SECRET!,
events,
})
console.log(event.type, event.id) // Process once: see below.
return new Response(null, { status: 204 })
} catch (error) {
if (error instanceof WebhookError && isRejection(error)) {
return new Response('Invalid webhook', { status: 400 })
}
throw error // 500: the sender retries.
}
}
const isRejection = (error: WebhookError) =>
['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code)During a secret rotation, pass both secrets: secret: [newSecret, oldSecret].
Share the event map
Use the same map as the sender, from a module that imports no server configuration or secrets:
import { z } from 'zod'
export const events = {
'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int().nonnegative() }),
}event is a union over the map. Checking event.type narrows event.data:
if (event.type === 'invoice.paid') {
event.data.amount // number
}An event type that is not in your map is rejected as PAYLOAD_INVALID. There is no "ignore unknown events" mode.
Respond with the right status
The sender retries 5xx and network errors, and gives up on most 4xx. Return 400 only when the request itself is bad:
| Error code | Meaning | Respond |
|---|---|---|
SIGNATURE_INVALID | Missing, malformed, or wrong signature headers | 400 |
SIGNATURE_EXPIRED | Timestamp outside the tolerance (five minutes by default) | 400 |
PAYLOAD_INVALID | Bad encoding, JSON, or envelope, an unknown event, or schema error | 400 |
INVALID_CONFIG | Your event map or secret configuration is wrong | 500 |
| Anything else | Your database, your business logic, a thrown validator | 500 |
Expected errors carry no payload values in their messages. Unexpected errors keep a cause for your logs; don't send it back.
Process each event once
The sender delivers at least once. A retry or a replay repeats the same event.id and body. Make the change and record the ID in one transaction, and only then respond. A table with a unique ID column is enough:
CREATE TABLE webhook_receipts (
event_id text PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);await client.query('BEGIN')
const receipt = await client.query(
'INSERT INTO webhook_receipts (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id',
[event.id],
)
if (receipt.rowCount) {
// First time we see this event: apply the change.
await client.query('INSERT INTO invoices (id, amount) VALUES ($1, $2)', [
event.data.invoiceId,
event.data.amount,
])
}
await client.query('COMMIT')
// Respond 2xx whether or not the event was new.The basic example runs exactly this and checks that a retry and a replay produce one invoice.
Keep old events decodable
Retries and replays can deliver an event published before your latest deploy. Keep existing event names backward compatible, and add invoice.paid.v2 for a breaking payload change. Deploy receiver support before the sender starts publishing a new event.
Verify another sender's format
verifyWebhook() authenticates any Standard Webhooks request, then leaves parsing to you:
import { verifyWebhook } from 'good-webhooks/verify'
verifyWebhook({ body: rawBody, headers: request.headers, secret })
// Parse the body only after this returns.See the verify reference for header rules, secret formats, and every option.