HatchedDocs
Guides

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:

  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; 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:

  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:

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.