# Verifying a signature

> HMAC over timestamp and body, and why an unverified payload is a stranger.

Do this before you act on anything. We sign the timestamp along with the body,
so a captured request cannot be replayed forever.

## The headers

```http
x-pidgeon-timestamp: 1757760062
x-pidgeon-signature: v1=4f1a9c…
```

The signature is HMAC-SHA256 over the exact bytes of `timestamp` + `.` +
`body`, hex-encoded, keyed with the secret that webhook was created with.

## Verify it

```ts
import { verifyWebhook } from '@pidgeon-ai/sdk';

export async function POST(req: Request) {
const { type, data } = await verifyWebhook({
  rawBody: await req.text(), // raw, not parsed
  headers: req.headers,
  secret: process.env.PIDGEON_WEBHOOK_SECRET!,
});

if (type === 'message.received') {
  // …
}

return new Response(null, { status: 204 });
}
```

```python
import os

from pidgeon.webhooks import WebhookVerificationError, verify_webhook

def handler(request):
  try:
      event = verify_webhook(
          raw_body=request.body,  # raw, not parsed
          headers=request.headers,
          secret=os.environ["PIDGEON_WEBHOOK_SECRET"],
      )
  except WebhookVerificationError:
      return 400

  if event["type"] == "message.received":
      ...

  return 204
```

Both do the timing-safe compare and the replay window for you. `headers` is
anything that maps a name to a value — a `dict`, Django's `request.headers`,
Flask's, Starlette's — matched case-insensitively.

## Three ways to get this wrong

**Parsing the body first.** The signature is over bytes. Parsing JSON and
re-serialising it changes key order, whitespace and unicode escapes, and the
signature then fails on a request that is perfectly valid. Read the raw body,
verify, *then* parse. This is the mistake, and it produces an error saying the
request is not signed when it plainly is.

**Comparing with `===`.** String comparison returns early on the first
differing byte, which leaks the signature a character at a time to anybody
patient. Use a timing-safe compare — `crypto.timingSafeEqual`,
`hmac.compare_digest`, or the SDK, which does it for you.

**Ignoring the timestamp.** A signature proves the body came from us. It does
not prove *when*. Anything older than five minutes is refused as a replay; the
tolerance is a parameter on `verifyWebhook`, and widening it should be a
decision rather than a default.

## Answer 2xx quickly

Do the work after you reply, not before. A handler that takes eleven seconds
is a handler we time out and retry, and the retry does the same work again.
Acknowledge, queue, return.

An event type you do not recognise is not an error — return `204` and ignore
it, or we will deliver it seven more times.
