> ## Documentation Index
> Fetch the complete documentation index at: https://docs.signa.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying webhook signatures

> Copy-pasteable Node and Python recipes (Standard Webhooks)

## TypeScript / Node — SDK helper

The `@signa-so/sdk` package exports a thin wrapper over
[`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks):

```ts theme={null}
import express from 'express';
import { verifyWebhookSignature } from '@signa-so/sdk';

const app = express();
const SECRET = process.env.SIGNA_WEBHOOK_SECRET!;

app.post(
  '/signa-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyWebhookSignature(
      {
        'webhook-id': req.header('webhook-id')!,
        'webhook-timestamp': req.header('webhook-timestamp')!,
        'webhook-signature': req.header('webhook-signature')!,
      },
      req.body.toString('utf-8'),
      SECRET,
    );
    if (!ok) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString('utf-8'));
    handle(event);
    res.sendStatus(200);
  },
);
```

Behaviour:

* Returns `true` for a valid signature against `SECRET`.
* Returns `false` on stale timestamps (>5 min skew, enforced by the
  reference library).
* Accepts rotation overlap (`v1,<curr> v1,<prev>`) — verifies if either
  entry passes.

The body MUST be the **raw** request bytes — no `JSON.parse` round-trip
first. Mismatched whitespace breaks the HMAC.

## TypeScript / Node — without the SDK

```ts theme={null}
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.SIGNA_WEBHOOK_SECRET!);
try {
  wh.verify(rawBody, {
    'webhook-id': req.header('webhook-id')!,
    'webhook-timestamp': req.header('webhook-timestamp')!,
    'webhook-signature': req.header('webhook-signature')!,
  });
} catch (err) {
  return res.sendStatus(401);
}
```

## Python

Install [`standardwebhooks`](https://pypi.org/project/standardwebhooks/):

```bash theme={null}
pip install standardwebhooks
```

```python theme={null}
from flask import Flask, request, abort
from standardwebhooks import Webhook, WebhookVerificationError
import os

app = Flask(__name__)
SECRET = os.environ["SIGNA_WEBHOOK_SECRET"]

@app.post("/signa-webhook")
def signa_webhook():
    raw_body = request.get_data(as_text=False)
    headers = {
        "webhook-id": request.headers["webhook-id"],
        "webhook-timestamp": request.headers["webhook-timestamp"],
        "webhook-signature": request.headers["webhook-signature"],
    }
    try:
        Webhook(SECRET).verify(raw_body, headers)
    except WebhookVerificationError:
        abort(401)

    event = request.get_json()
    handle(event)
    return "", 200
```

## Go

```go theme={null}
import "github.com/standard-webhooks/standard-webhooks/libraries/go/standardwebhooks"

wh, err := standardwebhooks.NewWebhook(secret)
if err != nil { /* ... */ }

err = wh.Verify(rawBody, http.Header{
    "Webhook-Id":        []string{r.Header.Get("webhook-id")},
    "Webhook-Timestamp": []string{r.Header.Get("webhook-timestamp")},
    "Webhook-Signature": []string{r.Header.Get("webhook-signature")},
})
```

## Idempotency

Use `webhook-id` (the value, not the body) as your **application-level**
idempotency key. The same alert delivered twice (retry, redeliver,
duplicate dispatch) will carry the same `webhook-id`, so your business
logic (creating tickets, sending notifications, writing to your own DB)
just needs to check "have I processed this id?" -- exactly-once behaviour
without a separate delivery coordination system.

### Important: do NOT dedup blindly on `webhook-id` alone at the infrastructure layer

A common pitfall: a queue/proxy in front of your handler does
`if (seenWebhookIds.has(headers['webhook-id'])) return;` and silently
drops legitimate retries. Our dispatcher reuses `webhook-id` across
retries on purpose — that's how application-level idempotency works —
but it means an infrastructure-layer dedup keyed only on `webhook-id`
will swallow a retry that your handler actually wanted to see (e.g. the
first attempt timed out before your handler committed).

If you need infrastructure-layer dedup (retry-storm protection, queue
fan-out, observability counters), key on the tuple
`(webhook-id, webhook-attempt)` instead. The `webhook-attempt` header
is the delivery attempt number — `1` for the first delivery, `2` for
the first retry, and so on.

```typescript theme={null}
// Good — infra-layer dedup that doesn't swallow retries
const dedupKey = `${headers['webhook-id']}:${headers['webhook-attempt']}`;
if (seenDeliveries.has(dedupKey)) return; // exact same attempt arrived twice
seenDeliveries.add(dedupKey);
```

```typescript theme={null}
// Application-layer idempotency — `webhook-id` is correct here
const alertId = headers['webhook-id'];
if (await alertsProcessed.has(alertId)) return; // already handled this alert
await processAlert(body);
await alertsProcessed.insert(alertId);
```

> **Security note:** `webhook-attempt` is NOT part of the signed envelope.
> Per the [Standard Webhooks spec](https://www.standardwebhooks.com/), only
> `webhook-id`, `webhook-timestamp`, and the body are signed. An attacker
> who replays a captured request can set any `webhook-attempt` value they
> like. Use it only for dedup-counting and observability — never as input
> to a security decision.

## Testing locally

Production webhook endpoints must be public HTTPS URLs, so you cannot register `http://localhost`. To exercise your verifier end-to-end before going live:

1. Run your handler locally on (say) port 4000.
2. Expose it with [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) -- both give you a public HTTPS URL that forwards to your local port.
3. Register the tunnel URL via [`POST /v1/webhooks`](/api-reference/monitoring/webhooks/create) and store the returned secret in your local env.
4. Trigger a test delivery with [`POST /v1/webhooks/{id}/test`](/api-reference/monitoring/webhooks/test). The envelope shape matches `alert.created`, so your verifier exercises the same path it will in production. Test deliveries are free and do not count toward auto-disable counters.

When you ship to production, `PATCH` the endpoint with the production URL ([Changing the URL](/guides/monitoring/webhooks#changing-the-url)) -- the same secret keeps working.

## Common pitfalls

* **Verify first, parse second.** Always validate the signature against the raw bytes before calling `JSON.parse`. Reject 401 on a failed verification and never touch the body.
* **Re-serialising the body.** Verify against the bytes you received,
  not against `JSON.stringify(JSON.parse(body))`. Whitespace matters.
* **Comma vs space in `webhook-signature`.** During rotation the header
  contains TWO entries separated by a SINGLE SPACE. Some libraries split
  on commas — make sure yours follows the spec.
* **Forgetting timestamp freshness.** A leaked secret + stale signature
  is replayable. The SDK helper enforces 5-min skew automatically; if you
  roll your own, do the same.
