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.evolved', '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('x-hatched-signature') ?? '';
  const timestamp = req.headers.get('x-hatched-timestamp') ?? '';

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

  const event = JSON.parse(rawBody);
  const deliveryId = req.headers.get('x-hatched-delivery');
  const eventType = req.headers.get('x-hatched-event');
  if (!deliveryId || !eventType) return new Response('missing metadata', { status: 400 });

  await handle({ deliveryId, eventType, payload: event });
  return new Response(null, { status: 202 });
}

The signature arrives in X-Hatched-Signature: sha256=<hmac_sha256_hex> and the timestamp in its own X-Hatched-Timestamp: <unix_seconds> header. Pass the timestamp via options.timestamp — without it the helper fails closed. The helper verifies the HMAC over `${timestamp}.${rawBody}`, rejects timestamps older than toleranceSeconds (default 300), and uses timingSafeEqual under the hood.

The framework adapters in @hatched/sdk-js/webhooks (verifyExpressRequest, verifyNextAppRequest, …) extract both the signature and the timestamp header for you — reach for them first.

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)

The signing scheme is identical across languages: HMAC-SHA256 over `${unix_timestamp}.${raw_body_bytes}` using the webhook secret. Read the signature from X-Hatched-Signature (strip the sha256= prefix to get the hex) and the timestamp from X-Hatched-Timestamp. Reject anything older than 5 minutes; compare digests in constant time.

import crypto from 'node:crypto';

// signatureHeader = req.headers['x-hatched-signature']  → "sha256=<hex>"
// timestampHeader = req.headers['x-hatched-timestamp']  → "<unix_seconds>"
export function verifyHatchedSignature(
  signatureHeader: string,
  timestampHeader: string,
  rawBody: Buffer,
  secret: string,
) {
  const ts = timestampHeader;
  const sig = signatureHeader.replace(/^sha256=/, '');
  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');

  if (sig.length !== expected.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}
import hashlib, hmac, time

# signature_header = request.headers['X-Hatched-Signature']  → "sha256=<hex>"
# timestamp_header = request.headers['X-Hatched-Timestamp']  → "<unix_seconds>"
def verify_hatched_signature(
    signature_header: str,
    timestamp_header: str,
    raw_body: bytes,
    secret: str,
) -> bool:
    ts = timestamp_header
    sig = signature_header.removeprefix('sha256=')
    if not ts or not sig:
        return False
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(
        secret.encode('utf-8'),
        f"{ts}.".encode('utf-8') + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(sig, expected)
package webhooks

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "strconv"
  "strings"
  "time"
)

// signatureHeader = r.Header.Get("X-Hatched-Signature")  → "sha256=<hex>"
// timestampHeader = r.Header.Get("X-Hatched-Timestamp")  → "<unix_seconds>"
func VerifyHatchedSignature(signatureHeader, timestampHeader string, rawBody []byte, secret string) bool {
  ts := timestampHeader
  sig := strings.TrimPrefix(signatureHeader, "sha256=")
  if ts == "" || sig == "" {
    return false
  }
  tsInt, err := strconv.ParseInt(ts, 10, 64)
  if err != nil {
    return false
  }
  if delta := time.Now().Unix() - tsInt; delta > 300 || delta < -300 {
    return false
  }

  mac := hmac.New(sha256.New, []byte(secret))
  mac.Write([]byte(ts + "."))
  mac.Write(rawBody)
  expected := hex.EncodeToString(mac.Sum(nil))
  return hmac.Equal([]byte(sig), []byte(expected))
}

Respond quickly

  • Return a 2xx within 10 seconds, or Hatched retries the delivery.
  • Up to 4 attempts (initial + 3 retries at +5s, +30s, +5min). After the fourth attempt fails 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 delivery id in the X-Hatched-Delivery header. Dedupe against it before side-effects:

if (await alreadyHandled(deliveryId)) return ack();
await recordHandled(deliveryId);
await doTheWork(payload);

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(endpointId, deliveryId);

Framework examples