Good Webhooks

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.

  1. Read the raw bodyBounded in size, as bytes. Not parsed JSON.
  2. Verify and parse

    parseWebhook() checks the signature and returns a typed event.

  3. Process onceRecord event.id with your change in one transaction.
  4. Respond

    2xx when done. 400 for bad requests, 500 to get a retry.

What a receiver does

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.

app/webhooks/route.ts
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:

events.ts
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 codeMeaningRespond
SIGNATURE_INVALIDMissing, malformed, or wrong signature headers400
SIGNATURE_EXPIREDTimestamp outside the tolerance (five minutes by default)400
PAYLOAD_INVALIDBad encoding, JSON, or envelope, an unknown event, or schema error400
INVALID_CONFIGYour event map or secret configuration is wrong500
Anything elseYour database, your business logic, a thrown validator500

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:

receipts.sql
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.

On this page