API Reference
Every endpoint reachable at api.passkeybridge.io/v1: the auth lane it takes, its request and response shape, what it refuses, and the dashboard-only functions that are not routed there.
Base URL and versioning
Every public endpoint is served from one host:
https://api.passkeybridge.io/v1/<function-name>A Cloudflare worker (docs/cloudflare-worker/api-proxy.js) takes the first path segment as the function name and forwards the request to that edge function. Only names on the worker's allowlist are routed. Anything else is answered at the edge:
HTTP/1.1 404 Not Found
x-pb-reason: unknown-function
{ "error": "Unknown function 'shield-nope'", "debug_id": "8f2c...", "hint": "See https://docs.passkeybridge.io for the list of public endpoints, or hit /v1/_debug/echo to confirm header delivery." }The worker forwards a fixed header list. Eighteen names are copied onto the upstream request: authorization, content-type, apikey, stripe-signature, x-client-info, x-pb-api-key, x-shield-api-key, x-pb-tenant-id, x-pb-test-mode, x-pb-signature, x-shield-signature, dpop, if-none-match, x-okta-verification-challenge and the four x-supabase-client-* headers. Everything else is dropped before the function sees it, so a header has to be on this list to be usable through this host. Signed ingest (x-pb-signature), DPoP shadow observation (dpop) and a conditional status-list read (if-none-match) all work through the public host.
Most functions refuse a direct call. Calling the Supabase functions host instead of api.passkeybridge.io answers 403 with {"error":"Direct access denied. Use https://api.passkeybridge.io/v1/{function}."} and the header x-pb-reason: edge-locked. The worker injects the shared secret that satisfies that check, so the /v1 host is the only supported entry point.
Worker health is GET https://api.passkeybridge.io/health (no /v1 prefix, no auth). It returns the worker version and isolate-local proxy counters. GET /health?deep=1 additionally proxies a real upstream call and answers 503 when that round trip fails.
The machine-readable spec disagrees with the code. GET /v1/openapi-spec serves one document and docs/openapi.yaml is a different one; both are out of step with the functions on request fields, action names and error shapes. Every statement in this guide was read from the function source. Treat the spec as a list of paths and nothing more.
Every endpoint below names its reachability: the /v1 path with the auth lane it accepts, or console-only, meaning it is invoked from the dashboard with a signed-in session and is not routed at api.passkeybridge.io. Console-only functions are listed in the final section.
Authentication
Two credentials reach the public host. Both resolve to one tenant.
API key. Send the raw key in x-pb-api-key (the legacy x-shield-api-key is still accepted) and the tenant UUID in x-pb-tenant-id. The server SHA-256 hashes the key and matches it against active rows for that tenant, then checks the scope the endpoint requires. API keys are prefixed with pb_live_ (production) or pb_test_ (sandbox).
Dashboard session token. Send Authorization: Bearer <supabase session token>. The function validates the token and calls is_tenant_admin, so this lane is tenant admins only. It is deemed to hold the admin scope, so it satisfies every scope check. Some functions take x-pb-test-mode: true to force this lane even when a key is also present.
curl -X POST https://api.passkeybridge.io/v1/shield-ingest \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: 00000000-0000-0000-0000-000000000000" \
-H "Content-Type: application/json" \
-d '{"event_type":"sim_swap","phone":"+15551234567"}'Refusals from the shared module carry a machine-readable x-pb-reason header:
| Status | Body error | x-pb-reason |
|---|---|---|
| 401 | Missing authorization or x-pb-api-key header | missing-key |
| 401 | Missing authorization header | missing-auth |
| 401 | Invalid API key | invalid-key |
| 401 | Invalid auth token | invalid-jwt |
| 403 | Tenant not found or user is not an admin | not-tenant-admin |
| 403 | API key missing 'ingest' scope | missing-scope |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | edge-locked |
Two endpoints take the key as a bearer token instead, because their clients cannot set a custom header: shield-scim and shield-okta-hooks read Authorization: Bearer <api key>.
Scopes, minting and revocation are covered in Authentication and Scopes and permissions.
Rate limits and quotas
Three independent limits can answer 429, and one can answer 402.
Per-IP limit. An in-isolate counter plus a database-backed counter, keyed {function}:{tenant}:{ip_hash}. Signal ingest uses the highest plan default (1,200 per minute) before the tenant is known; every other caller of the shared limiter uses its own bucket, 60 per minute by default.
Plan limit. Applied at the database layer once the tenant resolves: Starter 60, Pro 300, Enterprise 600, enterprise_dedicated 1,200 requests per minute, or the tenant's explicit rate_limit_per_minute when support has set one.
Per-tenant quota. Functions that pass enforceQuota call shield_check_tenant_quota for the minute scope: Enterprise 30,000, Pro 3,000, Starter 600, unknown plan 60 requests per minute across every caller of that tenant.
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Layer: l2
{ "error": "Too many requests. Please retry after 60 seconds.", "limit": 300, "remaining": 0, "layer": "l2" }The quota refusal has a different shape and its own reason header:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Scope: minute
x-pb-reason: quota-exceeded
{ "error": "tenant_quota_exceeded", "scope": "minute", "limit": 3000, "retry_after_seconds": 60 }Full behaviour, including the Cloudflare edge ceiling and the sandbox and Starter monthly caps, is in Rate limits and quotas.
Signal ingest
`POST /v1/shield-ingest` (API key with the ingest scope, or a tenant-admin session with x-pb-test-mode: true). The signal pipeline: validate, keyed-hash the identifier, match one playbook, run its actions, persist the event.
The tenant id comes from x-pb-tenant-id or from a trailing path segment (/v1/shield-ingest/<tenant-id>). GET and HEAD answer 200 {"status":"ok","function":"shield-ingest"} as a liveness probe; any other method answers 405.
Request body. At least one of event_type or signal_type is required; everything else is optional, and unknown fields are kept as event metadata.
| Field | Type | Notes |
|---|---|---|
event_type | string | 1 to 64 chars matching [a-zA-Z0-9_-]+. signal_type is an accepted alias. |
phone | string | 1 to 32 chars. Normalized to E.164 and keyed-hashed server side. |
phone_hash | string | 64 hex chars. Keyed again under the server pepper before storage and stamped hash_version 2; the callback envelope carries the digest as you sent it. Send phone when you can. |
subject_ref | string | 1 to 128 printable ASCII chars. Your own opaque correlation handle. Refused when it looks like an email address or a phone number. |
risk_score | number | 0 to 1 inclusive. |
source_id | string | UUID of the webhook source. |
credential_sha1 | string | 40 hex chars. Triggers the Have I Been Pwned range check. |
curl -X POST https://api.passkeybridge.io/v1/shield-ingest \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant-id>" \
-H "Content-Type: application/json" \
-d '{"event_type":"sim_swap","phone":"+15551234567","risk_score":0.9,"subject_ref":"acct_8412"}'Response.
{
"status": "ok",
"result": "playbook_executed",
"actions_count": 2,
"actions_failed_count": 0,
"latency_ms": 214,
"correlation_id": "pb-19a7c2f4e10-3b9f2a17"
}status is ok or warning (one or more actions failed). result is playbook_executed or no_matching_playbook. When credential_sha1 was checked, hibp_compromised and hibp_breach_count are added. The correlation id is generated by the function; an inbound x-pb-correlation-id header is ignored, so quote the value that comes back.
A legacy key carrying no scopes at all is accepted only if it predates 2026-09-03, and the response then carries x-pb-scope-warning: legacy-unscoped.
Errors.
| Status | Body | When |
|---|---|---|
| 400 | Invalid JSON body | Body is not JSON. |
| 400 | Missing tenant ID in path or x-pb-tenant-id header | No tenant id. |
| 400 | subject_ref must not be an email address or phone number... | x-pb-reason: invalid-subject-ref. |
| 401 | Missing x-pb-api-key header or Invalid API key | No key, or no active match for this tenant. |
| 401 | Webhook signature required for this tenant (x-pb-signature header missing) | Tenant set require_signed_ingest. x-pb-reason: signature-required. |
| 401 | Invalid webhook signature | HMAC mismatch. x-pb-reason: invalid-signature. |
| 402 | Monthly signal allotment reached (100) while your subscription is unpaid... | code: payment_required, x-pb-reason: payment-required. |
| 403 | API key missing 'ingest' scope | x-pb-reason: missing-scope. |
| 403 | Tenant is suspended. | Tenant is quarantined. |
| 404 | Unknown tenant | x-pb-reason: unknown-tenant. |
| 413 | Request body too large | Over the 64 KB cap. |
| 422 | Payload validation failed | Carries a details array of field errors. |
| 429 | Starter plan monthly signal limit reached (100). Upgrade at /pricing... | Free-tier month-to-date cap. |
| 429 | Sandbox and test-mode signal limit reached (1000 per 30 days)... | x-pb-reason: sandbox-cap. |
| 502 | Failed to persist signal event | The event row could not be written; nothing was metered. |
| 503 | Tenant lookup failed | x-pb-reason: tenant-lookup-failed. Retry. |
Confirm it worked. Dashboard > Events shows the row, with the decision path and full response times, the playbook result and each action's status. Sandbox and test-mode signals carry a Test badge and are excluded by the live filter.
Revocation cascade
`POST /v1/shield-cascade` (Enterprise; admin scope, so in practice a tenant-admin session token, because admin cannot be minted onto a key). Runs the four-subsystem revocation cascade for one hard signal.
GET and HEAD answer 200 as a liveness probe. Every request body carries tenant_id and action.
| Action | Effect |
|---|---|
cascade_execute | Classifies signal_type. A hard signal revokes across the four subsystems; a soft signal is recorded and routed to the trust engine with no cascade. |
cascade_dry_run | Same classification and counting with no writes. |
cascade_history | The tenant's cascade audit rows, newest first. limit defaults to 50 and is capped at 200. |
The classifier is a fixed list. Hard: sim_swap, sim_swap_detected, port_out, number_port, number_porting, device_compromise, ss7_intercept, account_takeover, scope_poisoning. Anything outside the hard and soft lists answers 400 Unknown signal type: <value>.
curl -X POST https://api.passkeybridge.io/v1/shield-cascade \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{"action":"cascade_execute","tenant_id":"<tenant-id>","signal_type":"sim_swap"}'{
"ok": true,
"signal_type": "sim_swap",
"signal_class": "hard",
"delegates_revoked": 3,
"negotiations_revoked": 1,
"shadows_frozen": 0,
"tunnels_torn_down": 2,
"cascade_latency_ms": 88,
"correlation_id": "pb-19a7c2f4e10-3b9f2a17",
"dry_run": false
}Errors. 400 for an unknown action, a missing tenant_id or a missing signal_type; 403 plan_required below Enterprise; 402 payment_required when the subscription is unpaid; 502 Cascade execution failed when a subsystem write fails. Confirm in Dashboard > Revocation, which lists each run and its counts.
Signal ingest performs its own inline revocation of agent delegates and A2A negotiations for a hard signal. This endpoint is the wider four-subsystem run and is triggered by hand or by an inbound CAEP event.
BLAST tunnels
`POST /v1/shield-blast` (Enterprise; API key with the ingest scope, or a tenant-admin session). Session tunnel lifecycle: X25519 key agreement, HKDF-SHA-256 session key, AES-256-GCM payloads. GET and HEAD answer 200 as a liveness probe.
| Action | Required fields | Returns |
|---|---|---|
blast_init | client_public_key_hex (64 hex chars), optional ttl_ms | serverPublicKeyHex and a session object |
blast_encrypt | session_id, plaintext (at most 1 MiB) | ciphertext, iv, sessionId |
blast_info | session_id | existence, expiry, teardown state, remaining lifetime |
blast_teardown | session_id | tornDown boolean |
blast_count | none | activeSessions |
curl -X POST https://api.passkeybridge.io/v1/shield-blast \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant-id>" \
-H "Content-Type: application/json" \
-d '{"action":"blast_init","tenant_id":"<tenant-id>","client_public_key_hex":"<64-hex>"}'{
"ok": true,
"correlationId": "pb-19a7c2f4e10-3b9f2a17",
"serverPublicKeyHex": "9f3c...",
"session": {
"sessionId": "1d4f...",
"keyFingerprint": "a71b...",
"createdAt": 1789000000000,
"ttlMs": 300000,
"entropyAudit": null
}
}Default session lifetime is 5 minutes. The salt and session id are drawn from the quantum-seeded DRBG. Private material is encrypted before it is stored, and a torn-down or expired session cannot encrypt again.
Errors. 400 for a missing tenant_id, an unknown action, a malformed public key or oversized plaintext; 403 plan_required below Enterprise; 403 missing scope; 500 when the tunnel module throws (an expired or torn-down session reports through blast_info). Confirm in Dashboard > Session tunnels.
Spatial binding
`POST /v1/shield-spatial-bind` (Enterprise; API key with the spatial scope only, no session lane). Records an NF-08 atomic fingerprint and either enrols it as a baseline or verifies a capture against one.
Every field is a digest computed on the device. No raw sensor data is sent.
| Field | Type | Notes |
|---|---|---|
action | string | enroll or verify |
tenant_id | string | required in the body |
user_hash, fingerprint_hash, emi_spectral_hash, thermal_variance_hash, device_identifier_hash | string | at least 16 chars each, required |
accelerometer_hash, network_jitter_hash | string | optional channels |
capture_duration_ms | number | must be at most 100 |
capture_timestamp | string | ISO 8601, checked against the database clock with a 500 ms tolerance |
credential_id, device_model, os_platform, sdk_version, channels, metadata | mixed | optional provenance |
curl -X POST https://api.passkeybridge.io/v1/shield-spatial-bind \
-H "x-pb-api-key: pb_live_..." \
-H "Content-Type: application/json" \
-d '{"action":"verify","tenant_id":"<tenant-id>","user_hash":"...","fingerprint_hash":"...","emi_spectral_hash":"...","thermal_variance_hash":"...","device_identifier_hash":"...","capture_duration_ms":84,"capture_timestamp":"2026-09-16T10:00:00.000Z"}'An enrolment answers 201 {"status":"enrolled","fingerprint_id":...,"binding_id":...,"capture_duration_ms":...,"clock_drift_ms":...,"response_ms":...}. A verification answers 200 with status: "verified" when the anomaly score is below 0.45, and 403 with status: "anomaly_detected" otherwise; both carry anomaly_score and anomaly_reasons. Scores add 0.8 for a device mismatch, 0.5 for an identical EMI and thermal pair and 0.4 for an identical jitter digest.
Errors. 400 for a malformed payload; 401 Missing x-pb-api-key header or 401 Invalid or inactive API key; 403 API key lacks 'spatial' scope; 403 plan_required below Enterprise; 422 NF08_TEMPORAL_WINDOW_EXCEEDED when capture_duration_ms is over 100; 422 NF08_CLOCK_DRIFT when the capture timestamp is outside tolerance; 404 NF08_NO_BASELINE when verifying with no enrolment. Confirm in Dashboard > Device bindings.
Hosted passkey relying party
`POST /v1/shield-passkey-rp` (Enterprise; API key with the passkey_rp scope, or passkey_rp_import for the bulk import action). Hosted WebAuthn relying party for your own domain. Register the relying-party configuration first from the dashboard; the function reads rp_id and its allowed origins from that row and revalidates them on every ceremony.
Sandbox keys are refused here with 403 sandbox_not_supported.
| Action | Fields | Returns |
|---|---|---|
register_options | rp_id, user_handle | options, challenge_handle, display_name_contract |
register_verify | rp_id, user_handle, challenge_handle, response | verified, credential_id, webauthn_user_id, device_type, backed_up, transports, aaguid, created_at |
auth_options | rp_id, optional user_handle | options, challenge_handle |
auth_verify | rp_id, challenge_handle, response | verified, credential_id, webauthn_user_id, user_verified, backed_up, new_counter |
list | rp_id, user_handle | webauthn_user_id and credential metadata (never the public key) |
delete | rp_id, user_handle, credential_id | deleted |
delete_user | rp_id, user_handle | erased and per-table delete counts |
import_credentials | rp_id, records (1 to 500), optional validate | batch_id, accepted, duplicates, rejected |
attest_session | rp_id, credential_id, optional session_ref | a hybrid-signed attestation JWT plus its detached ML-DSA proof |
The user handle you send is keyed-hashed before any lookup; PasskeyBridge stores the digest, a random WebAuthn user id and the credential public key. Challenges live for 5 minutes and are consumed atomically, so one challenge can never verify twice.
curl -X POST https://api.passkeybridge.io/v1/shield-passkey-rp \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant-id>" \
-H "Content-Type: application/json" \
-d '{"action":"auth_options","rp_id":"example.com","user_handle":"user-8412"}'Errors. 400 bad_request with an issues array of paths and codes; 401 verification_failed with a stable reason such as challenge_expired, origin_mismatch, counter_regression or user_handle_mismatch; 403 tenant_not_eligible for a quarantined, cancelled or unpaid tenant; 404 rp_not_configured when no active relying-party row matches rp_id; 409 credential_exists; 413 Payload too large. Rate limits are per handle for registration (10 per minute) and per tenant for authentication (300 per minute) and import (5 calls per minute).
Verifiable credentials
Four functions cover issuance, verification, presentation and status. Issuance requires the tenant to have a credential provider configured; otherwise it answers 422.
`POST /v1/shield-vc-issue` (API key with vc_issue, or a tenant-admin session). subject_did and credential_type are both required; claims, expiration_days (default 90), issuer_did, disclosure_frame and holder_public_jwk are optional. The issuer DID defaults to did:web:passkeybridge.io:tenants:<slug> and, when supplied, must be this tenant's own DID: the credential is signed with this tenant's key, so a foreign issuer would be a credential no issuer published a key for, and the request answers 400.
{
"credential": {
"jwt": "eyJ...",
"format": "jwt-vc",
"credential_id": "urn:uuid:...",
"issuer": "did:web:passkeybridge.io:tenants:acme",
"subject": "did:key:z6Mk...",
"type": "IdentityAttestation",
"issued_at": "2026-09-16T10:00:00.000Z",
"expires_at": "2026-12-15T10:00:00.000Z"
},
"pqc": { "algorithm": "ML-DSA-65", "key_fingerprint": "a71b...", "proof_format": "standard" },
"record_id": "…",
"latency_ms": 412
}Signing is hybrid: an ECDSA JWS (ES256 on P-256, or ES384 on P-384 for the ML-DSA-87 tier) plus a detached ML-DSA proof. A tenant whose proof-format preference is hmac keeps the older server-verified envelope instead. Sending a disclosure_frame produces an SD-JWT credential and adds an sd block of disclosures.
`POST /v1/shield-vc-verify` (any active API key for the tenant, or a tenant-admin session with x-pb-test-mode: true). Body vp_token plus the tenant id in the header, the path or the body. This endpoint forwards to the tenant's configured external verifier (walt.id, Trinsic or a custom endpoint) and enforces the tenant's accepted credential types and trusted issuers. It performs no scope check, so any active key of the tenant can call it. Returns verified, credential_type, issuer, subject_hash, constraint_failure, playbook_executed, actions_count and latency_ms.
`POST /v1/shield-vc-present` (API key with vc_verify, or a session token). action: "create_request" builds an OpenID4VP authorization request from a presentation definition; action: "verify" (the default) checks one vp_token or an array of at most 20, optionally against a presentation_definition, and optionally binds the result to a signal digest with bind_to_signal_hash.
`GET /v1/shield-vc-status` (public). ?credential_id= for one credential, ?credential_ids=a,b,c for up to 100, or ?issuer=<did>&type=statuslist for the StatusList2021 credential, which supports If-None-Match and answers 304. Status values are active, revoked, suspended, expired or unknown.
`POST /v1/shield-vc-status` (API key with vc_revoke, or a tenant-admin session). Actions revoke, suspend, reinstate and refresh; batch_status is public. Revocation is permanent: reinstate on a revoked credential answers 422, and a second revoke answers 409. Every status change rebuilds the status list and returns the new totals.
`POST /v1/shield-vc-oid4vci` is the OpenID4VCI pre-authorized code flow. metadata, token and credential need no key (the pre-authorized code and then the access token are the credential); offer requires vc_issue or a tenant-admin session. Pre-authorized codes and access tokens each live 5 minutes.
DID resolution
`GET /v1/shield-did-resolve` (public, no auth). Serves DID documents for PasskeyBridge tenants and resolves did:key. Only GET is accepted; anything else answers 405 Method not allowed. Use GET.
| Path or query | Returns |
|---|---|
/v1/shield-did-resolve or /v1/shield-did-resolve/.well-known | the platform DID document for did:web:passkeybridge.io, including the ML-DSA key that signs chain checkpoints |
/v1/shield-did-resolve/<tenant-slug> | that tenant's DID document, with its classical verification method and its ML-DSA method |
/v1/shield-did-resolve?did=did:key:z6Mk... | a full DID resolution result envelope |
/v1/shield-did-resolve?did=did:web:passkeybridge.io:... | a 302 redirect to the did.json URL |
curl https://api.passkeybridge.io/v1/shield-did-resolve/acmeResponses are application/did+json and cached for 300 seconds.
Errors. 400 for an unsupported DID method, for a did:web outside passkeybridge.io and its api subdomain, for a malformed tenant slug or for a path with more than one segment; 404 when the tenant does not exist or a did:key cannot be resolved; 502 Tenant lookup failed on a database error.
Service endpoints appear in a tenant document only when credentials are enabled for that tenant.
Agent delegation and A2A trust
Three functions, and none of them accepts an API key. The delegate lifecycle is a dashboard-session lane, the trust engine is service-role only, and agent-to-agent negotiation is a tenant-member lane.
`POST /v1/shield-agent-delegate`. action: "verify" is public: an agent presents agent_token and tenant_id, optionally a scope, and gets back authorized, agent_label, scopes, expires_at and trust_score. It is rate-limited to 30 requests per minute per IP. create, list and revoke require Authorization: Bearer <session token>; create and revoke additionally require the admin role.
curl -X POST https://api.passkeybridge.io/v1/shield-agent-delegate \
-H "Content-Type: application/json" \
-d '{"action":"verify","agent_token":"pb_agent_...","tenant_id":"<tenant-id>","scope":"verify"}'create takes agent_label, scopes (at most 20), expires_in_hours (at most 8760), initial_trust in (0, 1] and auto_revoke_below in (0, 1), which must be below initial_trust. The agent token is returned once and never again.
`POST /v1/shield-agent-trust` is internal. It requires the service-role key in Authorization, so a tenant cannot call it; shield-ingest and the intelligence worker do. Actions are record_activity, evaluate and evaluate_tenant.
`POST /v1/shield-a2a-handshake`. Actions initiate, attest, negotiate, renew, status and lookup require a session token whose user is a member of the tenant. invalidate is service-to-service only and requires the service-role key in x-service-auth.
The lifecycle is ordered: initiate creates a pending negotiation, attest runs the recursive attestation checks, negotiate computes the combined trust coefficient and derives limits. Calling one out of order answers 400 Cannot attest—status is 'X', expected 'pending' and the equivalents. initiate, attest, negotiate and renew each require a fresh nonce and timestamp; a reused nonce answers 409 with code: REPLAY_DETECTED. See Replay protection.
negotiate returns the coefficient, the trust state (full, read_only, verify_only or auto_revoked), max_transaction_value, max_transactions_per_minute and allowed_scopes, which is the intersection of both delegates' scopes narrowed by the trust state.
Errors. 401 Unauthorized—missing Bearer token; 403 Forbidden—not a member of this tenant; 403 MTLS_FAILED with per-delegate diagnostics; 404 Negotiation not found or One or both delegates not found in this tenant; 400 Agent cannot negotiate with itself. Confirm in Dashboard > Agents.
Cross-reference and shadow proxy
Both functions are routed at /v1, both are Enterprise, and both take a dashboard session token only. Neither accepts an API key.
`POST /v1/shield-cross-reference` binds a SIM attestation to a credential subject. Actions:
| Action | Fields | Notes |
|---|---|---|
establish | sim_signal_identifier, vc_subject_identifier, optional sim_carrier, vc_credential_type, vc_issuer, sim_event_time, vc_event_time | admin only. Both identifiers are keyed-hashed before storage. A duplicate answers 409. |
verify | entity_profile_hash, or both identifiers | returns the match score and method, the bound passkey flag and the post-quantum verification result |
bind_passkey | cross_reference_id, passkey_credential_id | admin only. The credential must already exist for the tenant. |
revoke | cross_reference_id | admin only |
list | none | the 100 most recent bindings, metadata only |
The match score comes from temporal proximity: 1.0 within 5 minutes, 0.95 within an hour, 0.85 within a day, 0.7 beyond that, and 0.8 when one of the two timestamps is missing. There is no lookup by digest; verify takes the identifiers or the profile hash.
`POST /v1/shield-shadow-proxy` issues legacy-format identifiers backed by an encrypted value.
| Action | Fields | Notes |
|---|---|---|
issue | user_identifier, proxy_type (virtual_cc, pnr_code or loyalty_id), expires_in_hours | admin only. The proxy value is returned once. |
resolve | identity_id | decrypts and returns the value. 410 once expired. |
revoke | identity_id | admin only |
list | optional user_identifier | metadata only; filtering by identifier is admin only |
blast_init, blast_resolve, blast_teardown | tunnel lifecycle | resolves a shadow identity through an encrypted tunnel |
Errors. 401 Missing authorization header or Invalid auth token; 403 for a non-admin action or a plan below Enterprise; 404 when the row is not this tenant's; 410 for an expired shadow identity or tunnel; 413 Payload too large. Confirm in Dashboard > SIM and credential bindings and Dashboard > Legacy identifiers.
Cached proofs
`POST /v1/shield-cached-proof` (Enterprise; dashboard session token only, no API key lane). Stores short-lived trust proofs that a client can validate while offline and revalidate when it reconnects.
| Action | Fields | Notes |
|---|---|---|
store | user_identifier, proof_payload, optional proof_type, trust_level, expires_in_hours | proof_type is trust_token (default), vc_cache or passkey_attestation; trust_level is standard (default), high or critical; lifetime is 1 to 720 hours, default 72 |
validate | proof_id, optional proof_payload | checks revocation and expiry; with a payload it also checks the content digest |
revalidate | proof_id | marks the proof seen online again and writes a cached_proof_revalidated event |
revoke | proof_id | admin only |
list | optional user_identifier | metadata only; filtering by identifier is admin only |
The payload is encrypted at rest and the identifier is keyed-hashed. When the tenant has post-quantum signing enabled, the proof carries a hybrid signature and records the entropy seed behind it.
{
"valid": true,
"integrity_checked": true,
"reason": "Proof is valid, integrity verified",
"proof_type": "trust_token",
"trust_level": "standard",
"issued_at": "2026-09-16T10:00:00.000Z",
"expires_at": "2026-09-19T10:00:00.000Z",
"network_available": true,
"revalidated_at": null
}A validation with no payload confirms existence and status only, and says so in reason.
Errors. 400 for a missing identifier or payload, an unknown action or an out-of-range lifetime; 401 for a missing or invalid session token; 403 below Enterprise, or for a non-admin revoke or identifier-filtered list; 404 when the proof is not this tenant's; 502 on a database failure. Confirm in Dashboard > Offline trust tokens.
Carrier lookup
`POST /v1/shield-carrier-lookup` (API key for the tenant, or a tenant-admin session). Queries a carrier intelligence provider and forwards the normalized result into the signal pipeline. It validates the key and enforces the per-tenant quota but checks no specific scope.
| Action | Fields | Notes |
|---|---|---|
lookup | tenant_id, phone, optional provider | provider is vonage (default) or twilio. The number is sent to the provider for that one call and only its keyed digest is kept |
record | tenant_id, phone_hash, provider, sim_swap_detected, optional ported, reachable, roaming, sim_swap_age_hours, carrier_name, carrier_type | Bring your own lookup: the answer from your own provider with a digest you computed. A body carrying a number is refused; risk_score is derived, not supplied. See Carrier provider setup |
providers | tenant_id | the configured provider list and their capabilities |
curl -X POST https://api.passkeybridge.io/v1/shield-carrier-lookup \
-H "x-pb-api-key: pb_live_..." \
-H "Content-Type: application/json" \
-d '{"action":"lookup","tenant_id":"<tenant-id>","phone":"+15551234567","provider":"vonage"}'{
"status": "ok",
"signal": {
"provider": "vonage",
"phone_hash": "6b2f...",
"signal_type": "sim_swap_detected",
"risk_score": 0.8,
"carrier_name": "EE",
"carrier_type": "mobile",
"ported": false,
"reachable": true,
"roaming": false,
"sim_swap_detected": true,
"sim_swap_age_hours": 2
},
"latency_ms": 640,
"forwarded_to_ingest": true
}The phone number is sent to the provider in the clear because the provider needs it, then keyed-hashed and never stored. The risk score adds 0.60 for a detected SIM swap, 0.20 more when it is under 24 hours old or 0.10 when under 72, 0.15 for a ported line and 0.10 when the line is unreachable. The raw provider response is stripped before the answer is returned.
The forward into shield-ingest carries your credentials rather than the service role, so the signal is attributed to your tenant and a sandbox lookup stays unmetered.
Errors. 400 for an unknown action, a missing tenant_id or a missing phone; on record, 400 phone_not_accepted_on_record, 400 risk_score_not_accepted_on_record, 400 invalid_phone_hash, 400 invalid_provider or 400 invalid_field with a detail naming the field; 401 or 403 from the shared auth module; 404 Unknown tenant; 502 Carrier lookup failed with a detail string that has any phone-shaped digit run redacted. Provider credentials for lookup are project secrets configured by PasskeyBridge rather than through this endpoint.
DPoP token binding
`POST /v1/shield-dpop` (API key with the dpop scope for bind, verify and nonce; a tenant-member session for introspect and a tenant-admin session for cleanup). Implements RFC 9449 proof-of-possession binding. GET and HEAD answer 200 as a liveness probe.
tenant_id is read from the body only and must be a UUID.
| Action | Fields | Returns |
|---|---|---|
bind | dpop_proof, access_token | status: "bound", binding_id, jkt, expires_at |
verify | dpop_proof, access_token, htm, htu | status: "verified", jkt, binding_id, token_bound |
nonce | none | dpop_nonce, also set as the DPoP-Nonce header |
introspect | jkt | recent bindings for that key thumbprint and an active count |
cleanup | none | purged, the number of expired bindings deleted |
Accepted proof algorithms are ES256, ES384, ES512, PS256, PS384 and PS512. The proof must carry typ: "dpop+jwt", a public jwk with no private key material, and htm, htu, iat, jti and ath claims (ath required since 2026-09-18). Clock skew tolerance is 60 seconds and a proof older than 300 seconds is rejected. A binding is consumed on first successful verify.
Errors. 400 for a malformed proof, an unsupported algorithm, a missing claim, an iat outside the window or an ath that does not match the access token; 401 DPoP proof signature verification failed; 401 with code: DPOP_REPLAY when the binding was already consumed; 403 with code: DPOP_BINDING_NOT_FOUND; 400 with code: DPOP_NONCE_REQUIRED and a fresh dpop_nonce when the binding expects one; 409 for a duplicate jti or an existing binding for the same token and key.
The proof travels in the JSON body for bind and verify. The separate shadow-observation path reads the DPoP request header, which the worker forwards, so observations of traffic arriving at this host record the proof the client actually sent. See DPoP proof of possession.
Shared signals and CAEP
`POST /v1/shield-sse-caep` implements the OpenID Shared Signals framework and CAEP 1.0. Three groups of actions with three different lanes.
Stream management (configure_stream, update_stream, delete_stream, verify_stream, list_streams, list_events, list_outbound, emit_event) takes a dashboard session token. list_streams and list_events need tenant membership; the rest need the admin role.
Inbound security event tokens (receive_set) takes a signed compact JWS in set_jwt. The gate is fail-closed: an unsigned body is refused, the token's iss is used only to locate a receiver stream for this tenant whose status is verified and which has a JWKS URL, and the signature is then verified against that JWKS along with the issuer, audience and expiry. Only then is the event processed. Receipt is rate-limited to 60 requests per minute per source.
Transmitter metadata (well_known) returns the issuer, JWKS URI, supported events and subject formats for a tenant.
curl -X POST https://api.passkeybridge.io/v1/shield-sse-caep \
-H "Content-Type: application/json" \
-d '{"action":"receive_set","tenant_id":"<tenant-id>","set_jwt":"eyJ..."}'Supported CAEP event types are session-revoked, token-claims-change, credential-change, assurance-level-change and device-compliance-change. Subject identifiers are keyed-hashed before storage. An event that maps to a hard signal runs the revocation cascade in process and the response reports its counts; a soft mapping is recorded as routed with no cascade.
Errors. 400 Missing 'action' field, 400 SET validation failed: <reason>, 400 for a SET that is not a compact JWS; 401 when set_jwt is absent; 403 No verified receiver stream is configured for this issuer.; 404 Tenant not found; 413 Request body too large. A duplicate jti answers 200 {"status":"duplicate"} so a transmitter does not retry.
emit_event accepts only the opaque subject format and a 64-character hex subject_identifier_hash, and queues one row per subscribed transmitter stream; delivery is performed asynchronously.
SCIM provisioning and Okta event hooks
SCIM v2 (`/v1/shield-scim/...`) takes the API key as a bearer token: Authorization: Bearer <key> with the scim scope. Every route below requires it, including the discovery routes; only a bare GET /v1/shield-scim with no resource answers unauthenticated, as a health probe. The admin scope is not consulted here, so the key must carry scim literally.
| Route | Effect |
|---|---|
GET /Users | list, with startIndex, count (max 200) and a userName eq or externalId eq filter |
POST /Users | link an existing PasskeyBridge account to this tenant. 201 on success. |
GET /Users/{id} | one user |
PUT /Users/{id} | replace; active: false deprovisions |
PATCH /Users/{id} | RFC 7644 PatchOp; supports active, displayName and externalId |
DELETE /Users/{id} | soft deprovision, 204 |
GET /Groups, GET /Groups/{id} | the three role groups: admin, member, viewer |
POST /Groups | returns the matching role group, 201 |
PATCH /Groups/{id} | add or remove members, which sets their role |
DELETE /Groups/{id} | 204, no effect: roles are structural |
GET /ServiceProviderConfig, /Schemas, /ResourceTypes | discovery |
userName is an email address at every supported IdP; provisioning resolves it to an existing account and refuses to create one. A userName with no matching account answers 400 with scimType: invalidValue and the message that the user must sign up first.
curl https://api.passkeybridge.io/v1/shield-scim/Users?count=10 \
-H "Authorization: Bearer pb_live_..."Errors use the SCIM error envelope: 401 Invalid SCIM bearer token, 403 API key does not have 'scim' scope, 404, 409 with scimType: uniqueness, 413 and 500.
`POST /v1/shield-okta-hooks` consumes Okta event hooks. It takes x-pb-tenant-id plus Authorization: Bearer <api key> whose scopes include okta_hooks, ingest or admin. Hook registration sends GET with x-okta-verification-challenge, which is echoed back as {"verification":"<challenge>"}; that header is one of the few the worker forwards. Requests are limited to 30 per minute per IP and each delivery is capped at 50 events, with truncated: true in the response when more were sent.
Handled events are user.lifecycle.suspend, user.lifecycle.deactivate, user.lifecycle.unsuspend, user.session.start, user.account.update_password and user.mfa.factor.deactivate. Anything else is recorded as a logged event. Errors: 400 Missing x-pb-tenant-id header, 401 Missing authorization or Invalid API key, 403 API key lacks required scope (okta_hooks or ingest), 404 Invalid tenant, 405 for any other method, 413 Payload too large.
Supply-chain attestation
`POST /v1/shield-supply-chain` (Enterprise; API key with the supply_chain scope, or a dashboard session token). Stores and verifies SLSA build provenance and CycloneDX SBOM components.
| Action | Fields | Returns |
|---|---|---|
attest | statement (in-toto Statement v1), optional sigstore_bundle, artifact_name, artifact_type, source_repo, source_ref, custom_trusted_builders | id, verified, slsa_level, checks, errors, latency_ms |
verify | provenance_id or artifact_digest | the stored record and its verification state |
ingest_sbom | sbom (CycloneDX JSON), optional sbom_raw, provenance_id, signer_identity, signature | sbom_digest, components_ingested, components_total, truncated |
list | optional limit (max 100), offset, source_repo | attestations, newest first |
A repeat attest for the same artifact and source digest pair answers 200 with duplicate: true and the existing id rather than storing a second row. SBOM ingestion caps at 500 components per call and reports truncated when more were supplied.
Errors. 400 Missing 'statement' (in-toto Statement v1) or Statement subject missing sha256 digest; 400 Provide 'provenance_id' or 'artifact_digest'; 401 Missing authorization; 403 plan_required below Enterprise; 404 Attestation not found; 422 SBOM contains no components; 500 on a storage failure. Confirm in Dashboard > Supply chain.
Entropy provenance receipts
`GET /v1/shield-entropy-receipt/<tenant-id>` (Enterprise; API key with the receipts scope). Returns a signed statement naming the DRBG seed behind one artifact's post-quantum signature, what the seed source reported, the seed's position in the entropy hash chain and the signed checkpoint that covers it. GET only; anything else answers 405.
Query parameters are artifact_type and artifact_id. The accepted types are webhook_delivery, security_event_token, credential, cached_proof, cross_reference and chain_checkpoint. All but the last take a UUID; chain_checkpoint takes a positive integer sequence number.
curl "https://api.passkeybridge.io/v1/shield-entropy-receipt/<tenant-id>?artifact_type=credential&artifact_id=<uuid>" \
-H "x-pb-api-key: pb_live_..."The response carries receipt (the claims), jwt, kid, issuer, a pqc block with the detached ML-DSA proof and its nonce, and a verification recipe. The receipt is signed under the tenant's own ES256 or ES384 key, so a third party verifies it from the tenant's DID document alone.
Errors. 400 invalid_artifact_type with the allowed list, or 400 invalid_artifact_id; 403 plan_required below Enterprise; 404 artifact_not_found when the artifact is not this tenant's; 409 no_seed_reference when the artifact predates seed recording or was signed without the post-quantum layer; 409 seed_record_not_found, which is a defect worth reporting; 429 when the per-tenant limit of 60 requests per minute is exceeded. Responses are never cached.
SDK integrity attestation
`POST /v1/shield-sdk-attestation` (any active API key for the tenant, or a dashboard session token; no specific scope, and the per-tenant quota applies). Records a client-side integrity self-check and reports whether the client is trusted. POST only.
| Action | Fields |
|---|---|
attest | tenant_id, payload with session_id, sdk_version, origin, code_hash, dom_clean, proxy_headers, fn_tostring_intact, debugger_clean, attestation_ms, client_timestamp |
list | tenant_id, optional limit (max 200), offset, threat_only |
When an SDK manifest row exists for the submitted sdk_version, the code hash is compared against the pinned value; otherwise the check is shape-only. The origin, user agent and client IP are hashed before storage, and the IP digest is keyed.
{
"id": "…",
"is_trusted": true,
"checks_passed": 6,
"checks_total": 6,
"threat_class": null,
"threat_details": [],
"created_at": "2026-09-16T10:00:00.000Z"
}list adds a 24-hour summary with the total, the trusted count, the threat count, a trust rate and a breakdown by threat class.
Errors. 400 Missing tenant_id, 400 Missing or invalid attestation payload, 400 Unknown action; 401 or 403 from the shared auth module; 409 with code: REPLAY_DETECTED when the same attestation digest arrives twice within 5 minutes; 413 Payload too large; 500 Failed to store attestation. Confirm in Dashboard > SDK integrity.
Predictive escalation ladder
`POST /v1/shield-predict` (Enterprise plus the Predictive Escalation Ladder add-on; API key with the ingest scope for predict, or admin for ladder_stats). Runs one ambiguous signal up the escalation ladder and reports the verdict with its false-escalation accounting. GET and HEAD answer 200 as a liveness probe.
| Action | Fields | Returns |
|---|---|---|
predict | tenant_id, signal_type, optional risk_score (0 to 1) and features | verdict, confidence, resolved_at, the per-rung rungs array, escalations, total_escalations, false_escalations, top_rung_reached, top_rung_available, total_latency_ms, correlation_id |
ladder_stats | tenant_id, optional window_days (1 to 365, default 30) | the aggregated false-escalation rate |
Only non-identifying aggregate features are forwarded to the model rung.
Errors. 400 Unknown action. Supported: predict, ladder_stats; 400 tenant_id required or signal_type required; 403 plan_required below Enterprise; 402 addon_required when the add-on has not been purchased, and 402 payment_required when the subscription is unpaid; 502 Failed to compute ladder stats.
Health, time and chain checkpoints
Four public endpoints need no credential, and one is provider-only.
`GET /v1/shield-health` returns platform readiness: the runtime, database connectivity, encryption key shape, billing configuration and the billing-gate cron heartbeat, each with its own status and latency, plus a provider block and any warnings. It answers 200 when every check passes and 503 otherwise.
`GET /v1/shield-ntp` returns the server clock from the database, with stratum and source. POST with client_time or client_epoch_ms, and optionally mode: "strict", returns the measured drift, whether it is within tolerance and the offset to apply. Tolerance is 5,000 ms by default and 500 ms in strict mode. Limited to 120 requests per minute per IP.
curl https://api.passkeybridge.io/v1/shield-ntp`GET /v1/shield-chain-checkpoint` returns the latest signed checkpoint over the audit, event and entropy-pool hash chains, with the ML-DSA public key and the recipe for verifying it. ?seq=N returns a specific checkpoint. POST is cron-only and requires the cron secret.
`POST /v1/shield-client-errors` is the browser error reporter. No authentication, rate-limited per IP, a 128 KB body cap and at most 25 entries per batch. Query strings are stripped from reported URLs before storage.
`POST /v1/stripe-webhook` is reachable but is for Stripe alone: the body must carry a valid stripe-signature, and an invalid one answers 400 Invalid webhook signature.
`GET /v1/openapi-spec` serves the OpenAPI document. As noted at the top of this guide, it does not match the functions field for field.
/v1/_debug/echo answers at the edge without calling any function and reports which headers reached the worker, with only the first 8 characters of any presented key.
Error responses
Errors are JSON. The shared helper returns {"error": "<message>"} and may add fields such as code, details, reason, issues, hint or billing_url. SCIM is the exception and uses the RFC 7644 error envelope.
| Status | When |
|---|---|
| 200 | Success. An acknowledged-and-ignored webhook event also answers 200. |
| 201 | Created: a SCIM user, a spatial enrolment. |
| 204 | SCIM deprovision or group delete. |
| 302 | did:web resolution redirect. |
| 304 | StatusList2021 read with a matching If-None-Match. |
| 400 | Missing or malformed field, unknown action, invalid JSON, unsupported DID method. |
| 401 | Missing or invalid credential, missing required signature, consumed DPoP binding. |
| 402 | payment_required (subscription unpaid) or addon_required (add-on not purchased). |
| 403 | Missing scope, caller lacks the admin role, plan_required, edge-locked direct call, spatial anomaly detected. |
| 404 | Unknown tenant, function not on the worker allowlist, resource not found for this tenant. |
| 405 | Wrong method for the endpoint. |
| 409 | Replay detected, credential already revoked, duplicate SCIM user, existing binding. |
| 410 | Expired shadow identity or tunnel. |
| 413 | Body over the endpoint's cap (64 KB, 256 KB or 128 KB depending on the function). |
| 422 | Semantically invalid: capture window exceeded, clock drift, schema mismatch, credentials not enabled. |
| 429 | Per-IP limit, per-tenant quota, sandbox cap or Starter monthly cap. |
| 500 | Unexpected server failure. |
| 502 | Upstream failure: a carrier provider, a database write, or the worker's own Upstream unreachable. |
| 503 | Tenant lookup failed, or a degraded deep health check. |
The x-pb-reason header disambiguates the common refusals so a client does not have to parse prose:
| Value | Meaning |
|---|---|
edge-locked | Called the Supabase host directly instead of api.passkeybridge.io. |
missing-key, missing-auth | No credential presented. |
invalid-key, invalid-jwt | Credential presented but not valid. |
not-tenant-admin | Session token is valid, the user does not administer this tenant. |
missing-scope | Key is valid and lacks the required scope. |
signature-required, invalid-signature | Inbound ingest signature policy. |
invalid-subject-ref | subject_ref looked like an email address or phone number. |
quota-exceeded | Per-tenant quota. |
sandbox-cap | Sandbox and test-mode rolling allotment. |
payment-required | Subscription unpaid. |
unknown-function | Not on the worker allowlist. |
upstream-auth | Upstream answered 401, 403 or 429 without its own reason header. |
upstream-unreachable | The worker could not reach the function. |
Every proxied response also carries x-pb-debug-id, the Cloudflare ray id. Quote it in a support ticket. Endpoint-specific codes and messages are in Error codes and troubleshooting.
Dashboard and internal functions
These functions are invoked from the dashboard with a signed-in session, and there is no public request to write against them. All but one are absent from the worker allowlist, so https://api.passkeybridge.io/v1/<name> answers 404 with x-pb-reason: unknown-function. The exception is shield-metrics, which is routed for its Prometheus exposition alone; its four JSON actions belong here with the rest.
| Function | Dashboard surface | Authorization |
|---|---|---|
shield-admin-mutations | most write actions across the dashboard | session token plus is_tenant_admin. Actions: provision_api_key, revoke_key, toggle_playbook, save_callback_url, rotate_signing_secret, save_vc_provider, provision_tenant_key, rotate_tenant_key, upsert_rp_config, deactivate_rp_config, restore_agent_scopes, slack_webhook_set, slack_webhook_delete, posture_alert_set, posture_alert_clear_slack, posture_alert_reveal_slack, posture_alert_test_slack |
shield-admin-query | platform owner console | session token, platform admin role and an email allowlist. Modes: metrics, ai, function_status, function_costs, top_tenants_by_cost, canary_verify |
shield-metrics | Observability | session token plus is_tenant_admin. Actions: summary, prometheus, anomalies, timeseries, latency. The prometheus action also accepts an API key carrying metrics or events at GET /v1/shield-metrics?action=prometheus&tenant_id=<uuid>; the four JSON actions refuse a key. |
shield-alerting | Observability, alert rules | session token plus is_tenant_admin, or pg_cron with the cron secret for evaluate. Actions: evaluate, list_rules, create_rule, update_rule, delete_rule, test_channel, list_history, acknowledge_alert |
shield-support | Support | session token plus is_tenant_admin, or the service role for playbook-triggered calls. REST: POST /, GET /, GET /{id}, PATCH /{id}, DELETE /{id}, POST /self-heal |
passkey | Settings, passkeys | session token; the relying party is a fixed origin allowlist |
shield-recovery | Recovery | session token |
shield-sso | SSO | session token plus is_tenant_admin |
shield-provenance-guard | Device provenance | session token plus is_tenant_admin (admin scope, direct console lane) |
shield-breakglass | Breakglass | session token plus is_tenant_admin (breakglass scope, direct console lane) |
shield-intelligence-review | Intelligence | session token plus is_tenant_admin |
shield-intelligence-worker | none | service role, called by ingest for Enterprise live traffic |
create-checkout, customer-portal | Billing | session token plus is_tenant_admin, with a fixed redirect origin allowlist |
send-contact-form | marketing site | unauthenticated, restricted by an Origin allowlist |
shield-usage-billing, shield-billing-alerter | none | service-role bearer or the cron secret |
Everything else under supabase/functions/ is a cron job, a sweep or an internal worker with no customer-facing request.
Related from the blog
- Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patternsengineering · 10 min read
- Designing an Identity Playbook Engine: From Signal to Action in One API Callengineering · 14 min read
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read