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.

Last reviewed September 18, 2026Fresh

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:

StatusBody errorx-pb-reason
401Missing authorization or x-pb-api-key headermissing-key
401Missing authorization headermissing-auth
401Invalid API keyinvalid-key
401Invalid auth tokeninvalid-jwt
403Tenant not found or user is not an adminnot-tenant-admin
403API key missing 'ingest' scopemissing-scope
403Direct 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.

FieldTypeNotes
event_typestring1 to 64 chars matching [a-zA-Z0-9_-]+. signal_type is an accepted alias.
phonestring1 to 32 chars. Normalized to E.164 and keyed-hashed server side.
phone_hashstring64 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_refstring1 to 128 printable ASCII chars. Your own opaque correlation handle. Refused when it looks like an email address or a phone number.
risk_scorenumber0 to 1 inclusive.
source_idstringUUID of the webhook source.
credential_sha1string40 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.

StatusBodyWhen
400Invalid JSON bodyBody is not JSON.
400Missing tenant ID in path or x-pb-tenant-id headerNo tenant id.
400subject_ref must not be an email address or phone number...x-pb-reason: invalid-subject-ref.
401Missing x-pb-api-key header or Invalid API keyNo key, or no active match for this tenant.
401Webhook signature required for this tenant (x-pb-signature header missing)Tenant set require_signed_ingest. x-pb-reason: signature-required.
401Invalid webhook signatureHMAC mismatch. x-pb-reason: invalid-signature.
402Monthly signal allotment reached (100) while your subscription is unpaid...code: payment_required, x-pb-reason: payment-required.
403API key missing 'ingest' scopex-pb-reason: missing-scope.
403Tenant is suspended.Tenant is quarantined.
404Unknown tenantx-pb-reason: unknown-tenant.
413Request body too largeOver the 64 KB cap.
422Payload validation failedCarries a details array of field errors.
429Starter plan monthly signal limit reached (100). Upgrade at /pricing...Free-tier month-to-date cap.
429Sandbox and test-mode signal limit reached (1000 per 30 days)...x-pb-reason: sandbox-cap.
502Failed to persist signal eventThe event row could not be written; nothing was metered.
503Tenant lookup failedx-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.

ActionEffect
cascade_executeClassifies 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_runSame classification and counting with no writes.
cascade_historyThe 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.

ActionRequired fieldsReturns
blast_initclient_public_key_hex (64 hex chars), optional ttl_msserverPublicKeyHex and a session object
blast_encryptsession_id, plaintext (at most 1 MiB)ciphertext, iv, sessionId
blast_infosession_idexistence, expiry, teardown state, remaining lifetime
blast_teardownsession_idtornDown boolean
blast_countnoneactiveSessions
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.

FieldTypeNotes
actionstringenroll or verify
tenant_idstringrequired in the body
user_hash, fingerprint_hash, emi_spectral_hash, thermal_variance_hash, device_identifier_hashstringat least 16 chars each, required
accelerometer_hash, network_jitter_hashstringoptional channels
capture_duration_msnumbermust be at most 100
capture_timestampstringISO 8601, checked against the database clock with a 500 ms tolerance
credential_id, device_model, os_platform, sdk_version, channels, metadatamixedoptional 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.

ActionFieldsReturns
register_optionsrp_id, user_handleoptions, challenge_handle, display_name_contract
register_verifyrp_id, user_handle, challenge_handle, responseverified, credential_id, webauthn_user_id, device_type, backed_up, transports, aaguid, created_at
auth_optionsrp_id, optional user_handleoptions, challenge_handle
auth_verifyrp_id, challenge_handle, responseverified, credential_id, webauthn_user_id, user_verified, backed_up, new_counter
listrp_id, user_handlewebauthn_user_id and credential metadata (never the public key)
deleterp_id, user_handle, credential_iddeleted
delete_userrp_id, user_handleerased and per-table delete counts
import_credentialsrp_id, records (1 to 500), optional validatebatch_id, accepted, duplicates, rejected
attest_sessionrp_id, credential_id, optional session_refa 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 queryReturns
/v1/shield-did-resolve or /v1/shield-did-resolve/.well-knownthe 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/acme

Responses 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:

ActionFieldsNotes
establishsim_signal_identifier, vc_subject_identifier, optional sim_carrier, vc_credential_type, vc_issuer, sim_event_time, vc_event_timeadmin only. Both identifiers are keyed-hashed before storage. A duplicate answers 409.
verifyentity_profile_hash, or both identifiersreturns the match score and method, the bound passkey flag and the post-quantum verification result
bind_passkeycross_reference_id, passkey_credential_idadmin only. The credential must already exist for the tenant.
revokecross_reference_idadmin only
listnonethe 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.

ActionFieldsNotes
issueuser_identifier, proxy_type (virtual_cc, pnr_code or loyalty_id), expires_in_hoursadmin only. The proxy value is returned once.
resolveidentity_iddecrypts and returns the value. 410 once expired.
revokeidentity_idadmin only
listoptional user_identifiermetadata only; filtering by identifier is admin only
blast_init, blast_resolve, blast_teardowntunnel lifecycleresolves 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.

ActionFieldsNotes
storeuser_identifier, proof_payload, optional proof_type, trust_level, expires_in_hoursproof_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
validateproof_id, optional proof_payloadchecks revocation and expiry; with a payload it also checks the content digest
revalidateproof_idmarks the proof seen online again and writes a cached_proof_revalidated event
revokeproof_idadmin only
listoptional user_identifiermetadata 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.

ActionFieldsNotes
lookuptenant_id, phone, optional providerprovider is vonage (default) or twilio. The number is sent to the provider for that one call and only its keyed digest is kept
recordtenant_id, phone_hash, provider, sim_swap_detected, optional ported, reachable, roaming, sim_swap_age_hours, carrier_name, carrier_typeBring 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
providerstenant_idthe 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.

ActionFieldsReturns
binddpop_proof, access_tokenstatus: "bound", binding_id, jkt, expires_at
verifydpop_proof, access_token, htm, htustatus: "verified", jkt, binding_id, token_bound
noncenonedpop_nonce, also set as the DPoP-Nonce header
introspectjktrecent bindings for that key thumbprint and an active count
cleanupnonepurged, 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.

RouteEffect
GET /Userslist, with startIndex, count (max 200) and a userName eq or externalId eq filter
POST /Userslink 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 /Groupsreturns 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, /ResourceTypesdiscovery

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.

ActionFieldsReturns
atteststatement (in-toto Statement v1), optional sigstore_bundle, artifact_name, artifact_type, source_repo, source_ref, custom_trusted_buildersid, verified, slsa_level, checks, errors, latency_ms
verifyprovenance_id or artifact_digestthe stored record and its verification state
ingest_sbomsbom (CycloneDX JSON), optional sbom_raw, provenance_id, signer_identity, signaturesbom_digest, components_ingested, components_total, truncated
listoptional limit (max 100), offset, source_repoattestations, 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.

ActionFields
attesttenant_id, payload with session_id, sdk_version, origin, code_hash, dom_clean, proxy_headers, fn_tostring_intact, debugger_clean, attestation_ms, client_timestamp
listtenant_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.

ActionFieldsReturns
predicttenant_id, signal_type, optional risk_score (0 to 1) and featuresverdict, 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_statstenant_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.

StatusWhen
200Success. An acknowledged-and-ignored webhook event also answers 200.
201Created: a SCIM user, a spatial enrolment.
204SCIM deprovision or group delete.
302did:web resolution redirect.
304StatusList2021 read with a matching If-None-Match.
400Missing or malformed field, unknown action, invalid JSON, unsupported DID method.
401Missing or invalid credential, missing required signature, consumed DPoP binding.
402payment_required (subscription unpaid) or addon_required (add-on not purchased).
403Missing scope, caller lacks the admin role, plan_required, edge-locked direct call, spatial anomaly detected.
404Unknown tenant, function not on the worker allowlist, resource not found for this tenant.
405Wrong method for the endpoint.
409Replay detected, credential already revoked, duplicate SCIM user, existing binding.
410Expired shadow identity or tunnel.
413Body over the endpoint's cap (64 KB, 256 KB or 128 KB depending on the function).
422Semantically invalid: capture window exceeded, clock drift, schema mismatch, credentials not enabled.
429Per-IP limit, per-tenant quota, sandbox cap or Starter monthly cap.
500Unexpected server failure.
502Upstream failure: a carrier provider, a database write, or the worker's own Upstream unreachable.
503Tenant 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:

ValueMeaning
edge-lockedCalled the Supabase host directly instead of api.passkeybridge.io.
missing-key, missing-authNo credential presented.
invalid-key, invalid-jwtCredential presented but not valid.
not-tenant-adminSession token is valid, the user does not administer this tenant.
missing-scopeKey is valid and lacks the required scope.
signature-required, invalid-signatureInbound ingest signature policy.
invalid-subject-refsubject_ref looked like an email address or phone number.
quota-exceededPer-tenant quota.
sandbox-capSandbox and test-mode rolling allotment.
payment-requiredSubscription unpaid.
unknown-functionNot on the worker allowlist.
upstream-authUpstream answered 401, 403 or 429 without its own reason header.
upstream-unreachableThe 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.

FunctionDashboard surfaceAuthorization
shield-admin-mutationsmost write actions across the dashboardsession 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-queryplatform owner consolesession token, platform admin role and an email allowlist. Modes: metrics, ai, function_status, function_costs, top_tenants_by_cost, canary_verify
shield-metricsObservabilitysession 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-alertingObservability, alert rulessession 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-supportSupportsession 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
passkeySettings, passkeyssession token; the relying party is a fixed origin allowlist
shield-recoveryRecoverysession token
shield-ssoSSOsession token plus is_tenant_admin
shield-provenance-guardDevice provenancesession token plus is_tenant_admin (admin scope, direct console lane)
shield-breakglassBreakglasssession token plus is_tenant_admin (breakglass scope, direct console lane)
shield-intelligence-reviewIntelligencesession token plus is_tenant_admin
shield-intelligence-workernoneservice role, called by ingest for Enterprise live traffic
create-checkout, customer-portalBillingsession token plus is_tenant_admin, with a fixed redirect origin allowlist
send-contact-formmarketing siteunauthenticated, restricted by an Origin allowlist
shield-usage-billing, shield-billing-alerternoneservice-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