Identity Signals

Signal types, the ingest request end to end, hard and soft classification, and what PasskeyBridge stores and sends on.

Last reviewed September 18, 2026Fresh

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-lookup queries Vonage or Twilio and forwards a normalized signal into the same pipeline. See Carrier provider setup.
  • Your own backend. Any event_type you choose, including application events such as passkey_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 typeMeaning
sim_swapSIM swap reported
sim_swap_detectedSIM swap confirmed by a carrier lookup
port_outNumber ported to another carrier
number_portPorting event
number_portingPorting event, alternative spelling
device_compromiseDevice integrity failure
ss7_interceptSignalling-layer interception
account_takeoverTakeover confirmed
scope_poisoningAgent 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.

  1. Edge lock. A request that did not come through the Cloudflare Worker is refused with 403 Direct access denied.
  2. Per-IP burst limit. 1,200 requests per minute per source IP, in memory.
  3. Organization id from the last path segment or x-pb-tenant-id.
  4. Body. Read under a size cap, parsed, then validated: event_type or signal_type (1 to 64 characters of letters, digits, underscore, hyphen), optional phone (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.
  5. `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.
  6. One database round trip fetching the organization, the API key row, the matching playbook and the rate-limit counter together.
  7. Organization state. Unknown organization 404, failed lookup 503, quarantined 403.
  8. 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.
  9. Authentication. Test mode verifies the JWT and is_tenant_admin; otherwise the key hash is looked up and its scopes checked.
  10. Inbound signature. A present x-pb-signature is 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.
  11. Unmetered ceiling. Test-mode and sandbox traffic share 1,000 signals per rolling 30 days.
  12. Identifier hashing. A phone becomes an HMAC-SHA-256 digest under a server-held pepper after E.164 normalization. A phone_hash you computed is keyed again under the same pepper as its own digest kind and stamped hash_version: 2; the digest as you sent it is carried on the callback envelope and accepted by shield-dsar, never stored.
  13. Starter monthly cap. Month-to-date billed signals against the allotment.
  14. Hard or soft response, before any playbook action, so revocation never waits on outbound work.
  15. Playbook actions, all at once, 8 seconds each.
  16. HIBP check when credential_sha1 was sent.
  17. Event row insert, then the usage counter and the audit row together.
  18. 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

StatusBody errorx-pb-reasonCause
400Missing tenant ID in path or x-pb-tenant-id headernoneNo organization id in the path or the header
400Invalid JSON bodynoneThe body did not parse
400subject_ref must not be an email address or phone number...invalid-subject-refsubject_ref was identifier-shaped
401Missing x-pb-api-key headernoneNo key on a non-test-mode request
401Invalid API keynoneNo active key with that hash on this organization
401Webhook signature required for this tenant (x-pb-signature header missing)signature-requiredThe organization requires signed signals
401Invalid webhook signatureinvalid-signatureThe HMAC matched neither the current nor the previous secret
402Monthly signal allotment reached...payment-requiredA paid plan whose billing status revoked entitlement
403Direct access denied. Use https://api.passkeybridge.io/v1/{function}.edge-lockedThe Supabase functions host was called directly
403API key missing 'ingest' scopemissing-scopeThe key names scopes but not ingest or admin
403Tenant is suspended.noneThe organization is quarantined
403Tenant ID header does not match path tenantnonePath and header disagree
404Unknown tenantunknown-tenantNo organization with that id
405Method not allowednoneAnything but POST, GET or HEAD
413Request body too largenoneBody over the JSON size cap
422Payload validation failednoneWith a details array naming each rejected field
429Too many requests. Please retry after 60 seconds.nonePer-IP or per-organization rate limit
429Sandbox and test-mode signal limit reached (1000 per 30 days)...sandbox-capThe unmetered ceiling
429Starter plan monthly signal limit reached (100)...noneThe Starter monthly allotment
502Failed to persist signal eventnoneThe event row could not be written
503Tenant lookup failedtenant-lookup-failedThe 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-id header.

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