# Verify webhooks end-to-end

> Copy-paste handlers that capture the raw body, verify the HMAC signature, and acknowledge fast — for Express, Fastify, Hono, Next.js App Router, Next.js Pages Router, and Cloudflare Workers.

Source: https://docs.hatched.live/docs/guides/verify-webhooks

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

Every Hatched webhook delivery is signed. Verification is non-negotiable —
without it, anyone who guesses your endpoint URL can spoof event traffic. The
verification rule is simple: compute `HMAC-SHA256(secret, ${unix_ts}.${rawBody})`
and compare it against the hex in the `X-Hatched-Signature: sha256=<hex>`
header. The `unix_ts` comes from the separate `X-Hatched-Timestamp` header.

This guide ships ready-to-paste handlers for the frameworks Hatched users hit
most often. Every example follows the same shape:

1. **Capture the raw body** — never the framework's parsed JSON. Any
   `JSON.parse → JSON.stringify` round-trip reorders keys and breaks the
   signature.
2. **Verify the signature** — with the SDK adapter for your framework, or
   the manual HMAC code if you're outside the Node ecosystem.
3. **Dedupe on the `X-Hatched-Delivery` header** — Hatched delivers
   at-least-once. The same event can arrive twice; your handler must be
   idempotent. The delivery id lives in the request header, not the body.
4. **Acknowledge fast** — return `2xx` in under 10 seconds. Push slow work
   to a queue.

The signing scheme is exhaustively covered in
[Handle webhooks](/docs/guides/handle-webhooks); this page is the framework
cookbook.

## SDK adapters

`@hatched/sdk-js` ships per-framework adapters that pull the raw body and
headers out of the framework's request shape. Every adapter returns the same
result: `{ valid, event, eventType, deliveryId, reason }`.

```ts
interface VerifyResult {
  valid: boolean;
  event: Record<string, unknown> | null; // raw parsed payload, no envelope
  eventType: string | null; // X-Hatched-Event
  deliveryId: string | null; // X-Hatched-Delivery, dedupe key
  reason?:
    | 'missing_header'
    | 'missing_secret'
    | 'missing_body'
    | 'invalid_signature'
    | 'invalid_json';
}
```

Import from the package root or the `/webhooks` deep import — both expose
the same functions.

## Express

Capture the body with `express.raw()` *before* any JSON middleware sees the
route. Order matters — put the raw-body parser on the webhook path only;
keep `express.json()` for the rest of the app.

```ts
import express from 'express';
import { verifyExpressRequest } from '@hatched/sdk-js';

const app = express();
const secret = process.env.HATCHED_WEBHOOK_SECRET!;

app.post(
  '/webhooks/hatched',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const { valid, event, deliveryId, reason } = verifyExpressRequest(req, secret);
    if (!valid || !event) {
      return res.status(400).json({ error: reason ?? 'invalid_signature' });
    }
    if (!deliveryId) return res.status(400).json({ error: 'missing_delivery_id' });
    if (await alreadyHandled(deliveryId)) return res.sendStatus(202);
    await enqueue(event);
    res.sendStatus(202);
  },
);
```

## Fastify

Register a content-type parser that hands you the raw bytes. Without this,
Fastify hands you parsed JSON and the signature check fails.

```ts
import Fastify from 'fastify';
import { verifyFastifyRequest } from '@hatched/sdk-js';

const fastify = Fastify();
const secret = process.env.HATCHED_WEBHOOK_SECRET!;

fastify.addContentTypeParser(
  'application/json',
  { parseAs: 'buffer' },
  (_req, body, done) => done(null, body),
);

fastify.post('/webhooks/hatched', async (req, reply) => {
  const { valid, event, deliveryId, reason } = verifyFastifyRequest(req, secret);
  if (!valid || !event) {
    return reply.code(400).send({ error: reason ?? 'invalid_signature' });
  }
  if (!deliveryId) return reply.code(400).send({ error: 'missing_delivery_id' });
  if (await alreadyHandled(deliveryId)) return reply.code(202).send();
  await enqueue(event);
  reply.code(202).send();
});
```

## Hono

Hono's `c.req.text()` returns the raw body string — no middleware
configuration needed. The adapter is `async` because it awaits the body.

```ts
import { Hono } from 'hono';
import { verifyHonoRequest } from '@hatched/sdk-js';

const app = new Hono();
const secret = Deno.env.get('HATCHED_WEBHOOK_SECRET')!;

app.post('/webhooks/hatched', async (c) => {
  const { valid, event, deliveryId, reason } = await verifyHonoRequest(c, secret);
  if (!valid || !event) {
    return c.json({ error: reason ?? 'invalid_signature' }, 400);
  }
  if (!deliveryId) return c.json({ error: 'missing_delivery_id' }, 400);
  if (await alreadyHandled(deliveryId)) return c.body(null, 202);
  await enqueue(event);
  return c.body(null, 202);
});
```

This is the same shape that runs on Bun, Deno Deploy, Cloudflare Workers,
and Vercel Edge — the Web Standards `Request` API is identical across
those runtimes.

## Next.js (App Router)

In App Router route handlers you receive a standard `Request` — call
`req.text()` to get raw bytes. The adapter does this for you.

```ts
// app/api/webhooks/hatched/route.ts
import { verifyNextAppRequest } from '@hatched/sdk-js';

const secret = process.env.HATCHED_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const { valid, event, deliveryId, reason } = await verifyNextAppRequest(req, secret);
  if (!valid || !event) {
    return Response.json({ error: reason ?? 'invalid_signature' }, { status: 400 });
  }
  if (!deliveryId) {
    return Response.json({ error: 'missing_delivery_id' }, { status: 400 });
  }
  if (await alreadyHandled(deliveryId)) {
    return new Response(null, { status: 202 });
  }
  await enqueue(event);
  return new Response(null, { status: 202 });
}
```

## Next.js (Pages Router)

Pages Router parses the body for you by default. Disable the parser, capture
raw bytes manually (e.g. with `raw-body`), then pass them to the adapter.

```ts
// pages/api/webhooks/hatched.ts
import getRawBody from 'raw-body';
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyNextPagesRequest } from '@hatched/sdk-js';

export const config = { api: { bodyParser: false } };

const secret = process.env.HATCHED_WEBHOOK_SECRET!;

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end();

  const rawBody = await getRawBody(req);
  const { valid, event, deliveryId, reason } = verifyNextPagesRequest(req, rawBody, secret);
  if (!valid || !event) {
    return res.status(400).json({ error: reason ?? 'invalid_signature' });
  }
  if (!deliveryId) return res.status(400).json({ error: 'missing_delivery_id' });
  if (await alreadyHandled(deliveryId)) return res.status(202).end();
  await enqueue(event);
  res.status(202).end();
}
```

## Cloudflare Workers / Edge runtimes

Same shape as Hono / App Router — the Web Standards `Request` API works
unchanged. Use `verifyNextAppRequest` (it's runtime-agnostic, despite the
name).

```ts
import { verifyNextAppRequest } from '@hatched/sdk-js';

export default {
  async fetch(req: Request, env: { HATCHED_WEBHOOK_SECRET: string }) {
    if (new URL(req.url).pathname !== '/webhooks/hatched') {
      return new Response('not found', { status: 404 });
    }
    const result = await verifyNextAppRequest(req, env.HATCHED_WEBHOOK_SECRET);
    if (!result.valid || !result.event) {
      return new Response(result.reason ?? 'invalid_signature', { status: 400 });
    }
    // Queue the event onto a Cloudflare Queue / Durable Object
    // for processing; ack immediately.
    return new Response(null, { status: 202 });
  },
};
```

## Outside Node — Python / Go / Ruby

The signing scheme is plain HMAC-SHA256. Anything that can read raw bytes
and compute a digest works. The [Handle webhooks](/docs/guides/handle-webhooks#manual-verification-without-the-sdk)
guide has Python and Go implementations side-by-side with the Node version.

## Idempotency in detail

Hatched stamps each delivery with a unique id in the `X-Hatched-Delivery`
header. Retries of the same event reuse the original id, so deduplication is
a single key lookup on that header value:

```ts
async function alreadyHandled(deliveryId: string): Promise<boolean> {
  // Any durable store works — Redis SETNX, Postgres unique constraint, KV.
  const inserted = await redis.set(`webhook:${deliveryId}`, '1', 'NX', 'EX', 86_400);
  return inserted === null; // null = key existed → already handled
}
```

Hold the dedupe record for at least 24 hours; Hatched stops retrying after
three failed attempts (+5s, +30s, +5min), but operators can replay manually
from the dashboard for longer.

## Replay protection — why the 5-minute window

Each delivery carries an `X-Hatched-Timestamp` header that Hatched signs
alongside the body (the HMAC is over `` `${timestamp}.${rawBody}` ``). The
SDK adapters reject anything older than 300 seconds by default — same as
Stripe, Slack, GitHub. This blocks attackers who somehow recorded a real
delivery from replaying it later.

If a request fails *only* because the timestamp is too old, log the delta
between now and `X-Hatched-Timestamp`. A consistent 30-second skew points at
clock drift on your host — fix NTP rather than widening the tolerance.

## Rotate the signing secret

Every adapter accepts either a single `string` secret or an array of strings.
Pass two during a rotation window so the verifier accepts payloads signed
under the previous *or* the new secret. The platform side flips to the new
secret the moment you call `rotateSecret` — your job is to give every host
in your fleet time to pick up the new value without dropping events in
between.

```ts
import { verifyExpressRequest } from '@hatched/sdk-js';

// During rotation, accept both. After every host has the new secret in
// its env, drop the previous one from the list.
const secrets = [
  process.env.HATCHED_WEBHOOK_SECRET,        // new
  process.env.HATCHED_WEBHOOK_SECRET_PREV,   // previous — empty/undefined OK
].filter((s): s is string => !!s);

app.post('/webhooks/hatched', express.raw({ type: 'application/json' }), (req, res) => {
  const { valid, event } = verifyExpressRequest(req, secrets);
  if (!valid || !event) return res.status(400).end();
  // ...
});
```

The recommended sequence:

1. Deploy your handler with `secrets = [old]`. Confirm green.
2. Add the rotation env var: `secrets = [old, new]`. Deploy. Both
   secrets are now accepted; nothing else has changed yet.
3. Call `client.webhooks.rotateSecret(endpointId)` — the response carries
   the new plaintext secret. Store it in your secrets manager and ship the
   `HATCHED_WEBHOOK_SECRET` env update.
4. Once every host has the new value, redeploy with `secrets = [new]`.
   Drop the previous secret from your secrets manager.

Hatched does not maintain a server-side grace window. After step 3, every
new delivery is signed with the new secret only. The `secrets = [old, new]`
window on the consumer side is what makes the rollout zero-downtime.

If you only have a single host, you can compress this to three steps: add
the new secret to the array, call `rotateSecret`, redeploy with the new
secret alone.

## Test before you ship

Dashboard → Developers → Webhook deliveries has a **Send test delivery**
button per endpoint. It produces the same signed request shape as live
deliveries, so your handler logic is exercised end-to-end. The SDK also
exposes a static helper for unit tests:

```ts
import { WebhooksResource } from '@hatched/sdk-js';

const ok = WebhooksResource.verifySignature(rawBody, signatureHeader, secret, {
  timestamp, // value of the X-Hatched-Timestamp header
});
expect(ok).toBe(true);
```

Pair that with a fixture rawBody and a known timestamp + secret to keep
your test deterministic.
