---
summary: Developer guide for receiving SigID webhooks, verifying signatures, handling retries, and designing safe event consumers.
tags:
  - developers
  - webhooks
  - events
  - signatures
categories:
  - For Developers
---

# Receive Webhooks

<!-- agent:page
You are a coding agent building a SigID webhook receiver in the user's backend by following this guide.
Webhooks are for asynchronous events (login, membership, organization, application, security, or audit changes), never for the immediate login response, which still returns through the browser callback.
First collect: which event types the app needs, the public HTTPS endpoint URL to register, and where the signing secret will be stored server-side.
Implementation order:
- create an HTTPS endpoint in the backend and register it in SigID
- store the signing secret server-side only, never in browser code
- verify the webhook signature before parsing trust-sensitive fields, and check the timestamp to limit replay windows
- make the handler idempotent keyed by event ID, respond quickly after verification, and queue slow work
- log event ID and request ID, never secrets or raw tokens
Treat unknown event types as safe no-ops or route them to review, and plan signing-secret rotation with deployments.
Verify: events with a bad signature or stale timestamp are rejected, and redelivered events do not double-apply. Event names and payloads are in reference/webhook-events.md.
-->

Use webhooks when your app needs to react to SigID events after they happen.
Examples include login, membership, organization, application, security, or
audit changes.

Do not use webhooks for the immediate login response. Login still returns
through the browser callback. Webhooks are for asynchronous updates.

## What You Build

<!-- agent:action Build the webhook endpoint
Create an HTTPS endpoint in the backend, register it in SigID, and store the signing secret server-side only.
In the handler, verify the webhook signature before trusting any field, check timestamp or replay protection, and make processing safe to retry.
Log event ID and request ID only; never log secrets or raw tokens. Verify a request with a bad signature is rejected before moving on.
-->

1. Create an HTTPS endpoint in your backend.
2. Register the endpoint in SigID.
3. Store the signing secret server-side.
4. Verify the webhook signature before trusting the event.
5. Check timestamp or replay protection.
6. Make the handler safe to retry.
7. Log event ID and request ID, not secrets or raw tokens.

## Receiver Checklist

| Step | Requirement |
|---|---|
| Endpoint | Use HTTPS in production. |
| Signing secret | Keep it server-side and never expose it in browser code. |
| Signature verification | Reject events before parsing trust-sensitive fields. |
| Timestamp check | Limit replay windows. |
| Retry handling | Make handlers idempotent and safe to run more than once. |
| Logging | Log event ID and request ID, not raw secrets. |

## Verifying The Signature (Copy-Paste)

SigID does **not** sign the raw body with a plain HMAC. Each delivery is
MACed over a **canonical string** with a key derived from your signing secret
via **HKDF-SHA256**. This binds the suite, timestamp, replay window, delivery
id, and event type into the tag, so a receiver cannot be tricked into widening
the replay window or accepting a weaker algorithm.

Suite identifier (published in `X-SigID-Signature-Suite`): **`sigid-webhook-v1`**.
Refuse to process a delivery whose suite header is missing or differs.

### Headers on every delivery

| Header | Meaning |
|---|---|
| `X-SigID-Signature-Suite` | `sigid-webhook-v1` (must match exactly) |
| `X-SigID-Signature-256` | `sha256=<hex>` MAC tag |
| `X-SigID-Signature-Max-Age` | Sender-bound replay window in seconds (default 300) |
| `X-SigID-Timestamp` | Sender clock at signing time, Unix seconds |
| `X-SigID-Event` | Event type, e.g. `commerce.payment.succeeded` |
| `X-SigID-Delivery` | Unique delivery id; deduplicate within the window |

### Key derivation and canonical string

1. Derive the HMAC key with HKDF-SHA256:
   - **ikm** = your signing secret as UTF-8 bytes
   - **salt** = `sigid:webhook-mac:v1`
   - **info** = `sigid-webhook-mac-v1`
   - **length** = 32 bytes
   - The secret must be **≥ 32 bytes**; shorter secrets are rejected.
2. Build the canonical string (fields joined by `\n`, in this exact order):

   ```text
   {suite}\n{timestamp}\n{max_age_secs}\n{delivery_id}\n{event_type}\n{raw_body}
   ```

   `raw_body` is the **verbatim** request body (do not re-serialize JSON).
3. `tag = HMAC-SHA256(key, canonical)`. Expected header value is
   `sha256=` + lowercase hex(tag).
4. Compare with the `X-SigID-Signature-256` header in **constant time**.
5. Enforce replay protection: `now - timestamp` must be in `[0,
   min(X-SigID-Signature-Max-Age, your_local_ceiling)]`. Deduplicate on
   `X-SigID-Delivery` within that window.

The canonical inputs below match the in-repo test vector in
`crates/sigid-core/src/crypto/mac/webhook.rs` (`sign_uses_sha256_prefix_and_hex_payload`),
so you can cross-check your implementation against the server's own fixture.

### Node.js verifier

```js
import crypto from "node:crypto";

const SUITE = "sigid-webhook-v1";
const LOCAL_MAX_AGE_SECS = 300; // clamp the sender's window to your ceiling

export function verifySigIdWebhook({ secret, headers, rawBody, nowMs = Date.now() }) {
  const suite = headers["x-sigid-signature-suite"];
  if (suite !== SUITE) throw new Error(`unsupported signature suite: ${suite}`);

  const signature = headers["x-sigid-signature-256"];        // "sha256=<hex>"
  const timestamp = headers["x-sigid-timestamp"];            // unix seconds
  const maxAge = Number(headers["x-sigid-signature-max-age"]);
  const deliveryId = headers["x-sigid-delivery"];
  const eventType = headers["x-sigid-event"];
  const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");

  if (!signature || !timestamp || !deliveryId || !eventType)
    throw new Error("missing required SigID signature headers");

  const ageSec = Math.floor(nowMs / 1000) - Number(timestamp);
  if (ageSec < 0 || ageSec > Math.min(maxAge, LOCAL_MAX_AGE_SECS))
    throw new Error(`timestamp outside replay window (age=${ageSec}s)`);

  const key = crypto.hkdfSync(
    "sha256",
    Buffer.from(secret, "utf8"), // ikm
    "sigid:webhook-mac:v1",      // salt
    "sigid-webhook-mac-v1",      // info
    32,                          // length
  );
  const canonical = [SUITE, timestamp, String(maxAge), deliveryId, eventType, body].join("\n");
  const expected =
    "sha256=" + crypto.createHmac("sha256", Buffer.from(key)).update(canonical).digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
    throw new Error("invalid signature");
  return { deliveryId, eventType };
}
```

### Python verifier

Requires the `cryptography` package (`pip install cryptography`).

```python
import hashlib
import hmac
import time
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

SUITE = "sigid-webhook-v1"
LOCAL_MAX_AGE_SECS = 300  # clamp the sender's window to your ceiling


def _derive_key(secret: str) -> bytes:
    if len(secret.encode("utf-8")) < 32:
        raise ValueError("webhook secret must be at least 32 bytes")
    return HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=b"sigid:webhook-mac:v1",
        info=b"sigid-webhook-mac-v1",
    ).derive(secret.encode("utf-8"))


def verify_sigid_webhook(*, secret, headers, raw_body, now_unix=None):
    suite = headers.get("x-sigid-signature-suite")
    if suite != SUITE:
        raise ValueError(f"unsupported signature suite: {suite!r}")

    signature = headers.get("x-sigid-signature-256", "")       # "sha256=<hex>"
    timestamp = headers.get("x-sigid-timestamp")                # unix seconds
    max_age = int(headers.get("x-sigid-signature-max-age", "0"))
    delivery_id = headers.get("x-sigid-delivery")
    event_type = headers.get("x-sigid-event")

    if not (signature and timestamp and delivery_id and event_type):
        raise ValueError("missing required SigID signature headers")

    now = now_unix if now_unix is not None else int(time.time())
    age = now - int(timestamp)
    if age < 0 or age > min(max_age, LOCAL_MAX_AGE_SECS):
        raise ValueError(f"timestamp outside replay window (age={age}s)")

    canonical = "\n".join(
        [SUITE, timestamp, str(max_age), delivery_id, event_type, raw_body.decode("utf-8")]
    )
    expected = "sha256=" + hmac.new(
        _derive_key(secret), canonical.encode("utf-8"), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise ValueError("invalid signature")
    return {"delivery_id": delivery_id, "event_type": event_type}
```

### Common mistakes

- Using a bare `HMAC(secret, body)` – this fails; the key is HKDF-derived and
  the canonical string is not the body alone.
- Re-serializing the JSON body before verifying – always MAC the raw bytes.
- Trusting the sender's `max-age` without a local ceiling – clamp it.
- Comparing signatures with `==` – use constant-time comparison.

## Event Handling Rules

<!-- agent:action Harden the event handler
Make the handler respond quickly after verification and push slow work onto a queue.
Implement idempotency keyed by event ID so retried deliveries do not double-apply, and treat unknown event types as safe no-ops or route them to review.
Document a signing-secret rotation plan tied to deployments before calling the receiver done.
-->

- respond quickly after verification
- queue slow work
- use idempotency keyed by event ID
- treat unknown event types as safe no-ops or route them to review
- rotate signing secrets with a deployment plan

For event names and payload reference, read
[Reference: Webhook Events](../reference/webhook-events.md).
