# Handle webhooks

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

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

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

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](/docs/reference/webhook-payloads)).
3. Copy the signing secret **once** — we don't show it again.

Programmatically:

```ts
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:

```ts
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 `parse`→`stringify` 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.

<Tabs items={['Node (TypeScript)', 'Python', 'Go']}>
  <Tab value="Node (TypeScript)">
    ```ts
    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'),
      );
    }
    ```
  </Tab>
  <Tab value="Python">
    ```py
    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)
    ```
  </Tab>
  <Tab value="Go">
    ```go
    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))
    }
    ```
  </Tab>
</Tabs>

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

```ts
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:

```ts
await hatched.webhooks.replay(endpointId, deliveryId);
```

## Framework examples

- [Next.js route handler](/docs/guides/nextjs-integration) — App Router,
  raw body, signature verify.
- [Express middleware](/docs/guides/express-integration) — `express.raw`
  + signature verify before JSON parsing.
- [Edge runtimes](/docs/guides/edge-runtimes) — Workers/Vercel Edge notes.
