# 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.

Source: https://good-webhooks.dev/docs/guides/receive



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.

What a receiver does:

1. **Read the raw body**: Bounded in size, as bytes. Not parsed JSON.
2. **Verify and parse**: `parseWebhook()` checks the signature and returns a typed event.
3. **Process once**: Record `event.id` with your change in one transaction.
4. **Respond**: `2xx` when done. `400` for bad requests, `500` to get a retry.

## Add a webhook route [#add-a-webhook-route]

Each handler is complete. Put your event map in `events.ts` ([shown below](#share-the-event-map)) and the endpoint's signing secret in `WEBHOOK_SECRET`.

**Next.js**

```ts title="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)
```

**Hono**

```ts title="server.ts"
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { parseWebhook, WebhookError } from 'good-webhooks/verify'
import { events } from './events.js'

const app = new Hono()

app.post('/webhooks', bodyLimit({ maxSize: 512 * 1024 }), async (c) => {
  try {
    const event = await parseWebhook({
      body: new Uint8Array(await c.req.arrayBuffer()),
      headers: c.req.raw.headers,
      secret: process.env.WEBHOOK_SECRET!,
      events,
    })
    console.log(event.type, event.id) // Process once: see below.
    return c.body(null, 204)
  } catch (error) {
    if (error instanceof WebhookError && isRejection(error)) return c.text('Invalid webhook', 400)
    throw error // 500: the sender retries.
  }
})

const isRejection = (error: WebhookError) =>
  ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code)

export default app
```

**Express**

```ts title="server.ts"
import express from 'express'
import { parseWebhook, WebhookError } from 'good-webhooks/verify'
import { events } from './events.js'

const app = express()

// Raw bytes, not parsed JSON: the signature covers the exact body.
app.post(
  '/webhooks',
  express.raw({ type: 'application/json', limit: '512kb' }),
  async (request, response) => {
    try {
      const event = await parseWebhook({
        body: request.body,
        headers: request.headers,
        secret: process.env.WEBHOOK_SECRET!,
        events,
      })
      console.log(event.type, event.id) // Process once: see below.
      response.sendStatus(204)
    } catch (error) {
      if (error instanceof WebhookError && isRejection(error)) {
        response.status(400).send('Invalid webhook')
        return
      }
      throw error // Express 5 turns this into a 500: the sender retries.
    }
  },
)

const isRejection = (error: WebhookError) =>
  ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code)

app.listen(3000)
```

Register `express.raw()` on this route before any global `express.json()`. Parsed JSON cannot be verified.

**Node.js http**

The plain `node:http` version needs a bounded reader. These helpers come from the checked examples:

```ts title="examples/basic/receiver.ts"
import type { IncomingMessage, ServerResponse } from 'node:http'
import { WebhookError } from 'good-webhooks/verify'

class BodyTooLargeError extends Error {}

/** Bound HTTP input while streaming, before allocating the complete body. */
export async function readBody(request: IncomingMessage): Promise<Buffer> {
  const limit = 512 * 1024
  if (Number(request.headers['content-length']) > limit) throw new BodyTooLargeError()
  const chunks: Buffer[] = []
  let size = 0
  // Keep the socket open long enough to return 413 if the stream exceeds the limit.
  for await (const chunk of request.iterator({ destroyOnReturn: false })) {
    const bytes = Buffer.from(chunk)
    size += bytes.length
    if (size > limit) throw new BodyTooLargeError()
    chunks.push(bytes)
  }
  return Buffer.concat(chunks, size)
}

/** Expected request failures are terminal. Unexpected server failures must remain retryable. */
export function respondToRequestError(error: unknown, response: ServerResponse): void {
  if (error instanceof BodyTooLargeError) {
    response.writeHead(413, { Connection: 'close' }).end('Webhook body is too large')
  } else if (
    error instanceof WebhookError &&
    ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code)
  ) {
    response.writeHead(400).end('Invalid webhook')
  } else {
    respondToProcessingError(error, response)
  }
}

/** Call for business failures regardless of their error class or code. */
export function respondToProcessingError(error: unknown, response: ServerResponse): void {
  console.error('Receiver could not process the webhook:', error)
  response.writeHead(500).end('Webhook processing failed')
}

```

```ts title="server.ts"
import { createServer } from 'node:http'
import { parseWebhook } from 'good-webhooks/verify'
import { events } from './events.js'
import { readBody, respondToRequestError } from './receiver.js'

createServer(async (request, response) => {
  try {
    const event = await parseWebhook({
      body: await readBody(request),
      headers: request.headers,
      secret: process.env.WEBHOOK_SECRET!,
      events,
    })
    console.log(event.type, event.id) // Process once: see below.
    response.writeHead(204).end()
  } catch (error) {
    respondToRequestError(error, response)
  }
}).listen(3000)
```

During a [secret rotation](https://good-webhooks.dev/docs/operations/key-rotation.md), pass both secrets: `secret: [newSecret, oldSecret]`.

## Share the event map [#share-the-event-map]

Use the same map as the sender, from a module that imports no server configuration or secrets:

```ts title="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`:

```ts
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.

**My sender's schema transforms values**

Receiver schemas may decode JSON into local values such as `Date`. But if a sender schema *transforms* its input, reusing it here would transform the already-transformed value again. Keep a separate receiver map that accepts the sender's output.

**My Zod schema has an async transform**

Zod 4's Standard Schema implementation tries synchronously first, then retries asynchronously, which can run an async transform twice. Wrap such schemas with the [`asyncZod` helper](https://github.com/pplytas/good-webhooks/blob/main/packages/good-webhooks/examples/basic/async-zod.ts) from the examples. Synchronous schemas need nothing.

## Respond with the right status [#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 [#process-each-event-once]

The sender delivers **at least once**. A retry or a [replay](https://good-webhooks.dev/docs/operations/retries-and-replay.md) 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:

```sql title="receipts.sql"
CREATE TABLE webhook_receipts (
  event_id text PRIMARY KEY,
  received_at timestamptz NOT NULL DEFAULT now()
);
```

```ts
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](https://github.com/pplytas/good-webhooks/blob/main/packages/good-webhooks/examples/basic/demo.ts) runs exactly this and checks that a retry and a replay produce one invoice.

## Keep old events decodable [#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 [#verify-another-senders-format]

`verifyWebhook()` authenticates any [Standard Webhooks](https://www.standardwebhooks.com) request, then leaves parsing to you:

```ts
import { verifyWebhook } from 'good-webhooks/verify'

verifyWebhook({ body: rawBody, headers: request.headers, secret })
// Parse the body only after this returns.
```

See the [verify reference](https://good-webhooks.dev/docs/reference/verify.md) for header rules, secret formats, and every option.
