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.

Last reviewed September 18, 2026Fresh

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.

DirectionWhat happensWhere
InboundA signal source posts an event to PasskeyBridgeshield-ingest, shield-okta-hooks
OutboundA playbook action posts an event to your serviceThe 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:

  1. You compute HMAC-SHA-256(raw request body, callback signing secret) and send it as lowercase hex in x-pb-signature. The legacy header x-shield-signature is also accepted.
  2. 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.
  3. For 24 hours after a rotation the previous secret still verifies, so senders can be updated without an outage.
SituationResponse
Valid signatureProcessing continues
Signature present and wrong401 "Invalid webhook signature", with x-pb-reason: invalid-signature
No signature, tenant requires one401 "Webhook signature required for this tenant", with x-pb-reason: signature-required
No signature, tenant does not require oneProcessing 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 header
import 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 header

Sign 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.

HeaderValue
x-pb-pqc-signatureBase64 ML-DSA signature
x-pb-pqc-algorithmML-DSA-65, or ML-DSA-87 for tenants on that add-on
x-pb-pqc-nonce64 hex characters, a quantum-seeded nonce mixed into the signed input
x-pb-pqc-kidThe 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.

AttemptWho makes itWhen
1shield-ingest, inline in your signal requestImmediately, with a five-second deadline
2shield-webhook-workerThe first cron run after the delivery is queued
3shield-webhook-workerThe first run at least four seconds after attempt 2
4shield-webhook-workerThe 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 responseRetried
2xxNo, the delivery is complete
429Yes
Any other 4xxNo, the delivery is marked failed at once
5xxYes
Network error, or the inline five-second deadlineYes

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"
  }
}
FieldTypeContents
eventstringThe signal type that matched the playbook
phone_hashstringKeyed 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_refstring or nullYour opaque correlation handle, echoed untouched; null when you sent none
tenant_idstringThe tenant that owns the event
timestampstringISO 8601, stamped once per delivery and repeated on every retry
metadataobjectYour 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.

HeaderValue
Content-Typeapplication/json
x-pb-signatureHMAC 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-kidPresent on worker attempts, absent on the inline first attempt

Delivery log

Every attempt, inline and worker alike, inserts a row into shield_webhook_deliveries.

FieldContents
urlThe destination
request_bodyThe full JSON envelope that was sent
response_statusThe receiver's status code, null when the connection failed
response_bodyFirst 2,000 characters of the response
attempt and max_attemptsWhich attempt this row is, out of four
statussuccess, failed or retrying
error_messageFor example an HTTP status, or the fetch error
latency_msThe time this attempt took, on its own
playbook_idThe playbook whose action fired
event_idAlways null on this path
pqc_kidThe DID key id behind the post-quantum proof, null when the delivery carried the HMAC alone
entropy_seed_hashThe DRBG seed the proof's nonce came from
created_atWhen 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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