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.
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:
- Capture the raw body — never the framework's parsed JSON. Any
JSON.parse → JSON.stringifyround-trip reorders keys and breaks the signature. - Verify the signature — with the SDK adapter for your framework, or the manual HMAC code if you're outside the Node ecosystem.
- Dedupe on the
X-Hatched-Deliveryheader — 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. - Acknowledge fast — return
2xxin under 10 seconds. Push slow work to a queue.
The signing scheme is exhaustively covered in 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 }.
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.
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.
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.
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.
// 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.
// 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).
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 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:
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.
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:
- Deploy your handler with
secrets = [old]. Confirm green. - Add the rotation env var:
secrets = [old, new]. Deploy. Both secrets are now accepted; nothing else has changed yet. - Call
client.webhooks.rotateSecret(endpointId)— the response carries the new plaintext secret. Store it in your secrets manager and ship theHATCHED_WEBHOOK_SECRETenv update. - 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:
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.