Receive Webhooks¶
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¶
- Create an HTTPS endpoint in your backend.
- Register the endpoint in SigID.
- Store the signing secret server-side.
- Verify the webhook signature before trusting the event.
- Check timestamp or replay protection.
- Make the handler safe to retry.
- 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¶
- 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.
- Build the canonical string (fields joined by
\n, in this exact order):
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¶
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).
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-agewithout a local ceiling – clamp it. - Comparing signatures with
==– use constant-time comparison.
Event Handling Rules¶
- 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.