Identity Signals
Signal types, the ingest request end to end, hard and soft classification, and what PasskeyBridge stores and sends on.
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-lookupqueries Vonage or Twilio 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. - 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 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.
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 present
x-pb-signatureis always verified against the current signing secret, or the previous one for 24 hours after a rotation. A missing one is refused only when the organization has Require signed signals switched on. - 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 does not work today. The Cloudflare Worker forwards a fixed header list that does not include x-pb-signature or x-shield-signature, so the header is dropped before it reaches the function: an organization with Require signed signals on is answered 401 on every request through api.passkeybridge.io. Leave the setting off until the header is forwarded.
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 |
| 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 | Invalid webhook signature | invalid-signature | The HMAC matched neither the current nor the previous secret |
| 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 |
| 404 | Unknown tenant | unknown-tenant | No organization with that id |
| 405 | Method not allowed | none | Anything but POST, GET or HEAD |
| 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 |
| 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 |
| 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 |
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.
Carrier integrations
shield-carrier-lookup wraps 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 Vonage or Twilio; 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 stored at hash version 1 and does not correlate with a hosted lookup's digest for the same number, so use one mode per identifier. Carrier credentials for lookup are platform-held function secrets; there is no per-organization provider configuration. 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 minus the raw identifiers phone, phone_number, msisdn, email, ip, ip_address and credential_sha1, which are removed before delivery and before the delivery row is written. Correlate on phone_hash, on subject_ref, or on your own passthrough fields.
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 the same bytes 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 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.
Related from the blog
- Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patternsengineering · 10 min read
- The Pre-Filter Pattern: Paying for Heavy Fraud Signals Only on the Suspicious Tailsecurity · 9 min read
- How to Benchmark an Identity Verification API: Latency, Freshness, Failure Injectionengineering · 12 min read