Webhook Configuration
Inbound and outbound webhooks: HMAC-SHA-256 signature verification on both sides, the four-attempt retry model, the post-quantum proof headers, the payload contract and the delivery log.
Webhook directions
PasskeyBridge uses webhooks in both directions. The two share a secret and share nothing else: separate code paths, separate policy switches, separate failure modes.
| Direction | What happens | Where |
|---|---|---|
| Inbound | A signal source posts an event to PasskeyBridge | shield-ingest, shield-okta-hooks |
| Outbound | A playbook action posts an event to your service | The URL on a webhook_callback action, or the tenant callback URL |
Both use HMAC-SHA-256 over the exact request body, keyed with the tenant's callback signing secret, which you rotate in Dashboard > Settings > Callback signing secret.
Outbound deliveries retry, up to four attempts. Inbound requests never retry: a signal PasskeyBridge rejected is your sender's to resend.
Everything below is the shipped behaviour of shield-ingest, its shared action handlers and shield-webhook-worker.
Inbound signature verification
shield-ingest verifies an inbound signature whenever the request carries one, and demands one when the tenant has opted in.
The policy lives in Dashboard > Settings > Require signed signals. While it reads Optional, an unsigned signal is accepted and a signed one is still verified. The button "Require signatures" flips it, after which a signal with no signature is refused.
How verification runs:
- You compute
HMAC-SHA-256(raw request body, callback signing secret)and send it as lowercase hex inx-pb-signature. The legacy headerx-shield-signatureis also accepted. - The function recomputes the digest over the exact bytes it received and compares them with WebCrypto's verify, which is constant-time. A signature that is not hexadecimal is rejected before any comparison.
- For 24 hours after a rotation the previous secret still verifies, so senders can be updated without an outage.
| Situation | Response |
|---|---|
| Valid signature | Processing continues |
| Signature present and wrong | 401 "Invalid webhook signature", with x-pb-reason: invalid-signature |
| No signature, tenant requires one | 401 "Webhook signature required for this tenant", with x-pb-reason: signature-required |
| No signature, tenant does not require one | Processing continues |
What a reader will see today: signed ingest cannot work through the public host. The Cloudflare worker in front of api.passkeybridge.io forwards a fixed list of headers to the function, and neither x-pb-signature nor x-shield-signature is on that list, so a signature you send is dropped before the function sees it. Against a tenant that requires signatures, every request therefore answers 401.
Computing the digest, sender side:
const crypto = require("crypto");
const body = JSON.stringify(payload);
const signature = crypto
.createHmac("sha256", process.env.PB_SIGNING_SECRET)
.update(body)
.digest("hex");
// POST `body` verbatim, with signature in the x-pb-signature headerimport hmac, hashlib, json
body = json.dumps(payload)
signature = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
# POST `body` verbatim, with signature in the x-pb-signature headerSign the bytes you actually send. Re-serializing the payload after signing changes the digest and the request fails verification.
Outbound webhook signatures
Every outbound delivery is signed with the tenant's callback signing secret, as lowercase hex in x-pb-signature, computed over the exact JSON string in the body. When the tenant has no secret configured the header is absent, so treat an unsigned delivery as unauthenticated.
Receiver side:
const crypto = require("crypto");
function verify(rawBody, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib
def verify(raw_body: str, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Verify against the raw bytes you received, never a re-serialized copy: a retry is rebuilt from the queue row, so key order can differ between attempts even though every value is identical.
The post-quantum layer. Deliveries sent by shield-webhook-worker, meaning every retried webhook, and every CAEP token sent by shield-sse-deliver, carry a detached ML-DSA signature (FIPS 204) beside the HMAC. The first, inline attempt at a webhook_callback carries the HMAC alone, so treat these four headers as present-or-absent, verify them when present, and gate on the HMAC.
| Header | Value |
|---|---|
x-pb-pqc-signature | Base64 ML-DSA signature |
x-pb-pqc-algorithm | ML-DSA-65, or ML-DSA-87 for tenants on that add-on |
x-pb-pqc-nonce | 64 hex characters, a quantum-seeded nonce mixed into the signed input |
x-pb-pqc-kid | The tenant's DID key id, ending #mldsa-1 |
The signed input is the raw body, then the literal :qnonce:, then the nonce header value, as one UTF-8 string. Fetch the tenant DID document from https://api.passkeybridge.io/v1/shield-did-resolve/{slug}, take the verificationMethod whose id equals the kid, and verify the signature against its publicKeyJwk with any FIPS 204 implementation:
input = rawBody + ":qnonce:" + headers["x-pb-pqc-nonce"]
ok = mldsa.verify(alg = headers["x-pb-pqc-algorithm"],
publicKey = didDocument.verificationMethod[kid].publicKeyJwk,
signature = base64decode(headers["x-pb-pqc-signature"]),
message = utf8(input))Nothing in this layer depends on the callback secret, and a receiver that ignores the four headers is unaffected. Verify the HMAC first when you have a secret; the post-quantum layer is the one that still holds if HMAC-SHA-256 is ever weakened, and the one you can hand an auditor years later with only a public key.
Retries and delivery guarantees
A webhook_callback gets four attempts in total.
| Attempt | Who makes it | When |
|---|---|---|
| 1 | shield-ingest, inline in your signal request | Immediately, with a five-second deadline |
| 2 | shield-webhook-worker | The first cron run after the delivery is queued |
| 3 | shield-webhook-worker | The first run at least four seconds after attempt 2 |
| 4 | shield-webhook-worker | The first run at least eight seconds after attempt 3 |
The worker runs every five minutes, and every backoff floor is shorter than that gap, so in practice each retry lands one cron run after the last. Expect gaps of minutes rather than seconds. A receiver that answers the first request never reaches the queue at all.
| Receiver response | Retried |
|---|---|
| 2xx | No, the delivery is complete |
| 429 | Yes |
| Any other 4xx | No, the delivery is marked failed at once |
| 5xx | Yes |
| Network error, or the inline five-second deadline | Yes |
Each worker attempt carries its own fifteen-second timeout. After the fourth attempt the queue row is marked failed and stops there: there is no dead-letter queue and no manual retry control in the dashboard.
Delivery is at-least-once. The envelope carries no event id and the transport sends no delivery-id header, and timestamp is stamped once when the envelope is built, so every retry of a delivery repeats the same value. Deduplicate on the combination of tenant_id, event, phone_hash and timestamp, or on a unique field you put in the ingest payload and read back out of metadata.
A webhook failure never blocks the ingest pipeline or the other actions in the playbook. The signal is still stored, the other actions still run, and your ingest call still returns; the delivery outcome shows up in the log rather than in the response.
Outbound payload format
{
"event": "sim_swap",
"phone_hash": "a1b2c3d4e5f6...",
"subject_ref": "user_8812",
"tenant_id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"timestamp": "2026-09-16T14:30:00.000Z",
"metadata": {
"carrier": "tmobile",
"swap_type": "port_out",
"risk_score": 0.92,
"source_id": "2c9a7b10-5d31-4f88-9a0e-7b6c5d4e3f21"
}
}| Field | Type | Contents |
|---|---|---|
event | string | The signal type that matched the playbook |
phone_hash | string | Keyed HMAC-SHA-256 digest of the phone number under a server-held pepper, or, when you sent phone_hash, that digest exactly as you supplied it (the stored copy of this envelope carries the keyed form) |
subject_ref | string or null | Your opaque correlation handle, echoed untouched; null when you sent none |
tenant_id | string | The tenant that owns the event |
timestamp | string | ISO 8601, stamped once per delivery and repeated on every retry |
metadata | object | Your ingest payload, minus raw identifiers, minus subject_ref |
metadata drops phone, phone_number, msisdn, email, ip, ip_address and credential_sha1 before the envelope is built, and subject_ref is lifted to the top level so you read it from one place. Everything else you sent survives, including custom fields. The playbook name is not included, and neither is the list of actions the playbook executed: that list lives on the event row and is readable through the events API.
| Header | Value |
|---|---|
Content-Type | application/json |
x-pb-signature | HMAC hex digest, present when a callback signing secret is configured |
x-pb-pqc-signature, x-pb-pqc-algorithm, x-pb-pqc-nonce, x-pb-pqc-kid | Present on worker attempts, absent on the inline first attempt |
Delivery log
Every attempt, inline and worker alike, inserts a row into shield_webhook_deliveries.
| Field | Contents |
|---|---|
url | The destination |
request_body | The full JSON envelope that was sent |
response_status | The receiver's status code, null when the connection failed |
response_body | First 2,000 characters of the response |
attempt and max_attempts | Which attempt this row is, out of four |
status | success, failed or retrying |
error_message | For example an HTTP status, or the fetch error |
latency_ms | The time this attempt took, on its own |
playbook_id | The playbook whose action fired |
event_id | Always null on this path |
pqc_kid | The DID key id behind the post-quantum proof, null when the delivery carried the HMAC alone |
entropy_seed_hash | The DRBG seed the proof's nonce came from |
created_at | When the attempt started |
Read it in Dashboard > Webhooks, which lists the 50 most recent deliveries for the tenant, newest first. A row shows the destination, an attempt count such as 2 of 4, the latency, the status code and a status pill; expanding it shows the error message, the full request body and the first 500 characters of the response.
Because event_id is null on every row this path writes, join a delivery back to its signal through playbook_id and created_at, or through a correlation value you put in the ingest payload and read back out of request_body.metadata.
Rows are kept indefinitely. No purge job deletes them, so treat the table as an append-only record when you plan retention on your side.
Configuring a callback
Outbound webhooks are playbook actions, and playbooks are created in the dashboard. There is no API action for creating one.
- Dashboard > Settings > Callback URL: enter the HTTPS URL deliveries should reach and click Save. This is the fallback destination for any webhook action that names none.
- Dashboard > Settings > Callback signing secret: click "Rotate secret". The value is shown once, and copied to your clipboard when the browser allows it. Store it on your receiver.
- Dashboard > Playbooks > "New playbook": choose the trigger event type, add a Webhook Callback action, and either leave its optional Callback URL blank to use the tenant URL or set a per-action URL.
- Save the playbook, then send a signal through it from Dashboard > Settings > Test signal.
The destination must be HTTPS. An http:// URL, a loopback or private address, a bare numeric IP or a cloud metadata host is refused before any request leaves, and the action result records blocked_ssrf.
A Slack action stores a reference and never a URL. Register the incoming webhook in the playbook builder, which asks for a Channel name and an Incoming webhook URL and stores the URL encrypted, and the saved action then carries {"type": "slack_webhook", "webhook_ref": "the registry row id"}. A database trigger refuses any playbook write that puts a Slack URL in the actions column, because that column is readable by every member of the tenant and an incoming-webhook URL is bearer-equivalent.
Rotating the signing secret takes effect immediately for outbound deliveries. For inbound signals the previous secret keeps verifying for 24 hours.
Practical notes for the receiver. Answer 2xx as soon as you have the body and do the work afterwards: a slow answer burns the five-second inline deadline and pushes the delivery onto the five-minute retry lane. Verify the signature before parsing. Deduplicate as described above, and watch Dashboard > Webhooks for a run of failed rows, which usually means a receiver misconfiguration rather than a PasskeyBridge one.
Related from the blog
- Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patternsengineering · 10 min read
- Running CAEP in Production: What Signing Outbound SETs Taught Us About Receiverssecurity · 13 min read
- ML-DSA-87 vs SLH-DSA-256: Choosing the Right Post-Quantum Signature for Long-Lived Identity Assertionsengineering · 19 min read