HatchedDocs
Guides

Handle webhooks

Verify the HMAC signature, respect the replay window, and respond before Hatched retries.

Webhooks are how your backend reacts to buddy events. Hatched signs every request so you can trust the payload came from us and hasn't been tampered with.

Subscribe

  1. Dashboard → Settings → Webhooks → Add endpoint.
  2. Pick the event types you care about (catalogue).
  3. Copy the signing secret once — we don't show it again.

Programmatically:

await hatched.webhooks.create({
  url: 'https://your-app.com/api/webhooks/hatched',
  events: ['buddy.leveled_up', 'badge.awarded', 'streak.milestone'],
});

Verify with the SDK helper

@hatched/sdk-js ships a static helper for signature verification:

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

export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get('hatched-signature') ?? '';

  const valid = WebhooksResource.verifySignature(
    rawBody,
    signature,
    process.env.HATCHED_WEBHOOK_SECRET!,
  );
  if (!valid) return new Response('invalid signature', { status: 400 });

  const event = JSON.parse(rawBody);
  await handle(event);
  return new Response(null, { status: 202 });
}

The header format is t=<unix_ts>,v1=<hmac_sha256_hex>. The helper verifies the HMAC, rejects timestamps older than toleranceSeconds (default 300), and uses timingSafeEqual under the hood.

Sign over raw body bytes. A JSON parsestringify round-trip reorders keys and breaks the signature. Read the body as Buffer or string before any framework middleware parses it as JSON.

Manual verification (without the SDK)

import crypto from 'node:crypto';

export function verifyHatchedSignature(header: string, rawBody: Buffer, secret: string) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const ts = parts.t;
  const sig = parts.v1;
  if (!ts || !sig) return false;

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${rawBody.toString('utf8')}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

Respond quickly

  • Return a 2xx within 10 seconds, or Hatched retries the delivery.
  • Retry schedule: +5s, +30s, +5min. After the third failure the delivery is marked failed in the delivery log but the state in Hatched is already correct.
  • If your handler is expensive, ack fast and push to a queue.

Idempotency

Every webhook carries a unique deliveryId. Dedupe against it before side-effects:

if (await alreadyHandled(event.deliveryId)) return ack();
await recordHandled(event.deliveryId);
await doTheWork(event);

Replay from the delivery log

Dashboard → Developers → Webhook deliveries shows every attempt with payload, headers, and response. Replay failed deliveries once your endpoint is healthy:

await hatched.webhooks.replay(deliveryId);

Framework examples