Identity Signals
The identity signal types PasskeyBridge ingests, the ingest request end to end, hard and soft classification, and what is stored and forwarded.
Identity signals
An identity signal is one event saying that the trust posture of a phone number, device or credential has changed. Signals arrive at POST https://api.passkeybridge.io/v1/shield-ingest/{tenant_id}, are matched against your playbooks, and are stored as rows in shield_events.
Where they come from.
- Carrier lookups.
shield-carrier-lookupasks a carrier provider about a number, through your own Vonage application or, with the hosted carrier lookups add-on, PasskeyBridge's Vonage and Twilio accounts, and forwards a normalized signal into the same pipeline. See Carrier provider setup. - Your own backend. Any
event_typeyou choose, including application events such aspasskey_login_failed. - Registered sources. An upstream system you register under Settings, Signal sources names itself with
source_idand signs with a secret of its own. See Registered signal sources. - The dashboard. Send test signal on the Overview tab, and Replay an attack in test mode, both in test mode.
Who can send one. An API key whose scopes include ingest (or admin), presented as x-pb-api-key. A key that names no scopes at all is accepted only if it was created before 2026-09-03, and the response then carries x-pb-scope-warning: legacy-unscoped. The dashboard's own test lane uses x-pb-test-mode: true with a tenant-admin session JWT instead. Ingest is on every plan; Starter is capped at 100 billed signals a month.
What is stored. The event row carries event_type, phone_hash, hash_version, source_id, the matched playbook_id, the actions_executed array, result, response_ms, decision_ms, risk_score, test_mode, subject_ref and a metadata object holding risk_score, correlation_id, playbook_matched, the first 8 characters of the keyed digest of the client IP as ip_prefix, source_verified: true when a registered source's secret verified the signal, and the HIBP fields when a credential check ran.
What is not stored. Any other field you send is validated and passed through, then dropped: it does not reach the event row. It is still delivered in the metadata of a webhook_callback envelope, minus raw identifiers, so a passthrough field such as your own user_id reaches your receiver but cannot be read back out of the Events tab.
The request ceilings, plan quotas and the three 429 response shapes are in the Rate Limits & Quotas Reference.
Hard and soft signals
Classification is a static list in the code, identical in the cascade classifier and in the function the ingest pipeline calls. It is not configurable per organization, and a new type needs a platform release.
Both paths require an identifier: the hard and soft response only runs when the signal carried phone or phone_hash. Without one, the signal is still stored and its playbook still runs.
Hard signals (nine types) revoke two subsystems inline for the whole organization: every active agent delegate, and every A2A negotiation in active, negotiated, attested or pending state.
| Signal type | Meaning |
|---|---|
sim_swap | SIM swap reported |
sim_swap_detected | SIM swap confirmed by a carrier lookup |
port_out | Number ported to another carrier |
number_port | Porting event |
number_porting | Porting event, alternative spelling |
device_compromise | Device integrity failure |
ss7_intercept | Signalling-layer interception |
account_takeover | Takeover confirmed |
scope_poisoning | Agent scope tampering |
Soft signals (ten types) dispatch one graduated trust evaluation instead, debounced to one per organization per signal type per 30 seconds: velocity_anomaly, behavioral_anomaly, geo_anomaly, credential_leak, credential_stuffing_attempt, suspicious_login, failed_verification, device_change, unusual_device, number_recycle. The evaluation re-scores every active delegate on the organization against its recent activity and may narrow scopes; it is fire and forget, so the ingest response never waits for it.
Every other type is accepted, stored and matched against playbooks, and triggers neither revocation nor trust evaluation. That includes test_signal, carrier_check (what a carrier lookup forwards when no swap was flagged) and your own application events.
The same unrecognised type behaves differently on the cascade endpoint, which refuses it with 400. See CASCADE signal classification.
The ingest request end to end
One request, in this order. Anything that fails returns immediately.
- Edge lock. A request that did not come through the Cloudflare Worker is refused with 403
Direct access denied. - Per-IP burst limit. 1,200 requests per minute per source IP, in memory.
- Organization id from the last path segment or
x-pb-tenant-id. - Body. Read under a size cap, parsed, then validated:
event_typeorsignal_type(1 to 64 characters of letters, digits, underscore, hyphen), optionalphone(up to 32 characters),phone_hash(64 hex),risk_score(0.0 to 1.0),source_id(UUID),credential_sha1(40 hex), and any additional fields. - `subject_ref`. Optional, 1 to 128 printable ASCII characters, refused when it looks like an email address, a phone number or a long digit run.
- One database round trip fetching the organization, the API key row, the matching playbook and the rate-limit counter together.
- Organization state. Unknown organization 404, failed lookup 503, quarantined 403.
- Distributed rate limit against the plan limit (Starter 60, Pro 300, Enterprise 600 per minute, or an explicit override), counted per organization per source IP.
- Authentication. Test mode verifies the JWT and
is_tenant_admin; otherwise the key hash is looked up and its scopes checked. - Inbound signature. A signal whose
source_idnames a registered source must be signed with that source's own secret, whatever the organization's setting (Registered signal sources). Otherwise a presentx-pb-signatureis always verified against the organization's current signing secret, or the previous one for 24 hours after a rotation, and a missing one is refused only when the organization has Require signed signals switched on. - Idempotency claim, when the request carries
x-pb-idempotency-key: a retry of an answered request gets the stored answer, a key still in flight answers 409, and a key reused with a different body answers 422. The key and the body are stored only as keyed digests. See Quickstart. - Unmetered ceiling. Test-mode and sandbox traffic share 1,000 signals per rolling 30 days.
- Identifier hashing. A
phonebecomes an HMAC-SHA-256 digest under a server-held pepper after E.164 normalization. Aphone_hashyou computed is keyed again under the same pepper as its own digest kind and stampedhash_version: 2; the digest as you sent it is carried on the callback envelope and accepted by shield-dsar, never stored. - Starter monthly cap. Month-to-date billed signals against the allotment.
- Hard or soft response, before any playbook action, so revocation never waits on outbound work.
- Playbook actions, all at once, 8 seconds each.
- HIBP check when
credential_sha1was sent. - Event row insert, then the usage counter and the audit row together.
- Intelligence worker dispatched fire and forget for Enterprise organizations on live traffic only.
The request:
curl -sS -X POST https://api.passkeybridge.io/v1/shield-ingest/$PB_TENANT_ID \
-H "content-type: application/json" \
-H "x-pb-api-key: $PB_API_KEY" \
-d '{"event_type":"sim_swap","phone":"+14155550123","risk_score":0.94,"subject_ref":"acct_8f21"}'The response:
{
"status": "ok",
"result": "playbook_executed",
"actions_count": 3,
"actions_failed_count": 0,
"latency_ms": 141,
"correlation_id": "pb-19a2f3c4d5e-7b1c9f04"
}status is warning when any action recorded an error, ok otherwise. result is playbook_executed or no_matching_playbook. actions_count includes the automatic entries, so a hard signal with two playbook actions reports three. hibp_compromised and hibp_breach_count are added when a credential hash was checked.
Signed ingest through the public host. api.passkeybridge.io forwards x-pb-signature, its alias x-shield-signature and x-pb-idempotency-key to the function, so an organization can turn on Require signed signals and send every signal through the public host.
Errors and status codes
| Status | Body error | x-pb-reason | Cause |
|---|---|---|---|
| 400 | Missing tenant ID in path or x-pb-tenant-id header | none | No organization id in the path or the header |
| 400 | Invalid JSON body | none | The body did not parse |
| 400 | subject_ref must not be an email address or phone number... | invalid-subject-ref | subject_ref was identifier-shaped |
| 400 | x-pb-idempotency-key must be 1 to 128 printable ASCII characters | invalid-idempotency-key | A malformed idempotency key |
| 401 | Missing x-pb-api-key header | none | No key on a non-test-mode request |
| 401 | Invalid API key | none | No active key with that hash on this organization |
| 401 | Webhook signature required for this tenant (x-pb-signature header missing) | signature-required | The organization requires signed signals |
| 401 | Signals from a registered source must be signed with that source's secret (x-pb-signature header missing) | source-signature-required | The source_id names a registered source and the header is missing |
| 401 | Invalid webhook signature | invalid-signature | The HMAC matched neither the current nor the previous secret (the source's, for a registered source) |
| 401 | Webhook signature is outside the 300 second window | signature-expired | x-pb-signature was t=<unix seconds>,v1=<hex> and the timestamp was more than 300 seconds from the server clock |
| 402 | Monthly signal allotment reached... | payment-required | A paid plan whose billing status revoked entitlement |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | edge-locked | The Supabase functions host was called directly |
| 403 | API key missing 'ingest' scope | missing-scope | The key names scopes but not ingest or admin |
| 403 | Tenant is suspended. | none | The organization is quarantined |
| 403 | Tenant ID header does not match path tenant | none | Path and header disagree |
| 403 | This signal source has been revoked | source-revoked | The source_id names a revoked source |
| 404 | Unknown tenant | unknown-tenant | No organization with that id |
| 405 | Method not allowed | none | Anything but POST, GET or HEAD |
| 409 | A request with this idempotency key is still being processed | idempotency-in-flight | The key's first request has not answered; carries Retry-After: 2 |
| 413 | Request body too large | none | Body over the JSON size cap |
| 422 | Payload validation failed | none | With a details array naming each rejected field |
| 422 | This idempotency key was already used for a different request body... | idempotency-key-reused | Send a new key for a new signal |
| 429 | Too many requests. Please retry after 60 seconds. | none | Per-IP or per-organization rate limit |
| 429 | Sandbox and test-mode signal limit reached (1000 per 30 days)... | sandbox-cap | The unmetered ceiling |
| 429 | Starter plan monthly signal limit reached (100)... | none | The Starter monthly allotment |
| 500 | The signal source's secret could not be read | source-secret-unusable | A registered source's stored secret could not be decrypted |
| 502 | Failed to persist signal event | none | The event row could not be written |
| 503 | Tenant lookup failed | tenant-lookup-failed | The organization read itself failed |
| 503 | Signal source lookup failed | source-lookup-failed | The source named by source_id could not be read; it is never treated as unregistered |
| 503 | Idempotency store is temporarily unavailable; the request was not run... | idempotency-unavailable | Retry with the same key |
A 429 carries Retry-After: 60. On the per-organization layer it also carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Layer and, when the distributed check could not be consulted, X-RateLimit-Degraded: true. On the per-IP layer the X-RateLimit-Limit header currently reports 60 even though the ceiling enforced there is 1,200; read the limit from your plan rather than from that header.
Registered signal sources
A signal source is an upstream system you register so that its signals can be told apart from anyone else's and verified with a secret of its own. Register one under Settings, Signal sources: give it a label of 1 to 80 characters that names the system (an email address, phone number or IP address is refused, because the label is stored and audited in the clear). PasskeyBridge answers with the source's id and its signing secret, pb_src_ followed by 64 hex characters. The secret is shown once and stored only encrypted; it is never returned again, logged or written to an audit row.
Sending as a source. Every source posts to the same ingest URL. Put the source's id in the body as source_id and sign with the source's secret. x-pb-signature is t=<unix seconds>,v1=<hex>, and the hex is HMAC-SHA-256 of <unix seconds>.<raw body>. The timestamp must be within 300 seconds of the server clock. A bare hex digest of the raw body is accepted only while that organization has not required timestamps, and only until 2027-01-31. Each acceptance is counted. A signal that names a registered source must carry that signature whatever Require signed signals says, and only that source's secret verifies it; the organization's signing secret does not. A verified signal is stored under the source's id with source_verified: true in its metadata, and its audit row names the source.
| Situation | Answer |
|---|---|
No x-pb-signature | 401, x-pb-reason: source-signature-required |
| The signature does not verify under the source's secret | 401, invalid-signature |
| The timestamp is more than 300 seconds from the server clock | 401, signature-expired |
| The source was revoked | 403, source-revoked |
| The source could not be read | 503, source-lookup-failed |
| The source's stored secret could not be decrypted | 500, source-secret-unusable |
A source_id that no source of yours has | Handled as before sources existed: stored as sent with no verified meaning, and signed or not by your organization's own setting |
Rotating. Rotate a source's secret under Settings, Signal sources. The new secret is shown once, and the previous one keeps verifying for 24 hours so the sender can switch over. A second rotation inside those 24 hours is refused unless you force it, which is for a previous secret that is compromised: a sender still using it fails at once.
Revoking. Revoking deletes both secrets and keeps the id reserved, so a signal naming it is refused with 403 rather than accepted as unregistered. Register a new source to start again.
Limits. Active sources per organization: 1 on Starter, 5 on Pro, 25 on Enterprise. A paid organization whose subscription has lapsed is held to Starter's limit. Registering past the limit is refused with 409 source_limit_reached, and a label another active source already has with 409 source_label_taken. Registering, rotating and revoking each write an audit row.
Carrier integrations
shield-carrier-lookup wraps Vonage Identity Insights, Vonage Number Insight Advanced and Twilio Lookup v2 into one request shape, derives a risk score, and forwards the result into this pipeline as an ordinary signal under your own credentials.
The forwarded event_type is sim_swap_detected when the provider flagged a swap, and carrier_check otherwise. carrier_check is not in either classification list, so a clean lookup matches playbooks and does nothing else.
Two ways to produce a carrier signal. lookup: PasskeyBridge asks a provider; the number is sent to the provider for that one call and only its keyed digest is stored, audited or forwarded. record: you ask your own provider under your own contract and post the normalized answer with a phone_hash you computed; no number reaches PasskeyBridge, and a body carrying one is refused. Both produce the same signal. A digest you compute is keyed again at rest as its own kind (hash version 2), so it does not correlate with a hosted lookup's digest for the same number: use one mode per identifier.
Whose account a lookup uses depends on the provider. vonage_identity_insights runs a SIM swap check through your own Vonage application, registered under Settings, Carrier lookups, and Vonage bills your account. sinch_number_lookup runs Sinch Number Lookup through your own Sinch project, registered under Settings, Sinch Number Lookup, and Sinch bills your account: it reports the carrier, the line type and porting, and a SIM swap for US and Canada numbers when Sinch has approved your project for it. A lookup that names no provider runs under your own registered account, the Vonage application first. vonage and twilio run on PasskeyBridge's own carrier accounts and need the hosted carrier lookups add-on; vonage is Number Insight, which reports the carrier, porting, reachability and roaming but no SIM swap. The full request, response and risk-score rules are in Carrier provider setup.
Outbound webhook delivery
When a matched playbook carries webhook_callback, the event is delivered to the action's url, or to the organization's saved callback URL from Settings.
Payload:
{
"event": "sim_swap",
"phone_hash": "9f2c4a...",
"subject_ref": "acct_8f21",
"tenant_id": "YOUR_TENANT_ID",
"timestamp": "2026-09-16T14:30:00.000Z",
"metadata": { "risk_score": 0.94, "your_field": "passed through" }
}metadata echoes the fields you sent, scrubbed before delivery and before the delivery row is written: any key whose name marks an identifier (phone, msisdn, email, ip, ssn, passport, dob, birth, and credential_sha1) is removed at any depth, identifier-shaped strings are masked, integers of seven or more digits are dropped, and objects nested eight levels deep are dropped. Correlate on phone_hash, on subject_ref, or on a passthrough field of your own that neither looks like an identifier nor is named like one, such as a UUID under event_ref.
HMAC signing. With a callback signing secret configured, the digest of the exact body goes in x-pb-signature. Verify against the raw bytes you received.
Post-quantum layer on retries. A delivery made by shield-webhook-worker, which today means any retry, also carries a detached ML-DSA (FIPS 204) signature over that JSON body followed by :qnonce: and a quantum-seeded nonce. The signature, algorithm, nonce and key id travel in x-pb-pqc-signature, x-pb-pqc-algorithm, x-pb-pqc-nonce and x-pb-pqc-kid, verifiable against the key published in your organization's DID document as #mldsa-1. The HMAC on x-pb-signature covers the JSON body. The first, inline attempt carries the HMAC alone, because ML-DSA signing is kept out of the highest-traffic function. Treat the four headers as present-or-absent and gate on the HMAC.
Retry policy. Attempt 1 runs inside the ingest request with a 5-second deadline. A 429, a 5xx, a network error or that deadline queues the delivery; shield-webhook-worker runs every 5 minutes and makes attempts 2 to 4 with backoff capped at 30 seconds and a 15-second deadline each. Other 4xx responses are terminal. Four attempts in total, and the queue row carries the attempt already spent so the log reads as one sequence.
Delivery log. Every attempt is written to shield_webhook_deliveries with the URL, the request body, the response status, the response body truncated to 2,000 characters, the attempt number, the status and the latency, and is visible in Dashboard > Webhooks.
Slack and email alert actions
Neither action stores a credential or an address on the playbook, because shield_playbooks.actions is readable by every member of the organization.
Slack. A slack_webhook action stores webhook_ref, the id of a row in the encrypted per-organization destination registry. At send time the pipeline resolves the reference, checks it belongs to your organization, decrypts the URL and posts a Block Kit message carrying the signal type, the risk score, the first 12 characters of the identifier hash and the first 8 of the organization id. Destinations are registered in the Playbook Builder with Add a Slack destination, must start https://hooks.slack.com/, and are capped at 25 per organization. A database trigger refuses any playbook write that carries a Slack URL, so there is no way back to storing one. When the reference is missing, malformed, unknown or inactive, the action records skipped_no_webhook_ref, skipped_invalid_webhook_ref, skipped_unknown_webhook_ref or skipped_webhook_inactive.
Email. An email_alert action stores recipient_scope, either tenant_admins or tenant_members. Addresses are resolved at send time through a service-role-only function that returns only members of your organization, so an alert cannot reach an arbitrary address and cannot reach your end users. Older playbooks that carry an explicit recipients array still work, filtered to verified member addresses; anything outside that list is dropped and, if nothing survives, the action records skipped_no_allowed_recipients.
Mail goes out through Resend from alerts@updates.passkeybridge.io and is bounded by a daily cap per organization: 100 on Starter, 500 on Pro, 2,000 on Enterprise. Past the cap the action records skipped_daily_cap and the signal is otherwise processed normally.
Correlation IDs
Every ingest request generates a unique correlation ID in the format pb-<timestamp_hex>-<random_hex> (23 characters at present millisecond widths) that propagates through the entire pipeline:
- The correlation ID appears in the ingest response as
correlation_id. - It is stored in the event's metadata.
- It is forwarded to async downstream services (trust evaluation, intelligence worker) via the
x-pb-correlation-idheader.
Use correlation IDs to trace a single signal from ingestion through playbook execution, trust evaluation, and intelligence classification across the audit log.