API Reference
Every endpoint at api.passkeybridge.io/v1: its auth lane, request and response shape, what it refuses, and the dashboard-only functions 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. Unknown fields are passed through to a webhook_callback envelope's metadata, scrubbed of identifiers, and are not stored on the event row.
Optional headers. x-pb-signature: t=<unix seconds>,v1=<hex> where the hex is HMAC-SHA-256 of <unix seconds>.<raw body>, required when the organization requires signed signals and for any signal whose source_id names a registered source (Registered signal sources). The timestamp must be within 300 seconds of the server clock. A bare hex digest of the raw body is accepted only while that organization has not required timestamps, and only until 2027-01-31. Each acceptance is counted. x-pb-idempotency-key: 1 to 128 printable ASCII characters, one per signal you intend to deliver once (Quickstart).
| 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. When it names a source registered to your organization, the signal must be signed with that source's secret; any other value is stored as sent. |
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. |
| 400 | x-pb-idempotency-key must be 1 to 128 printable ASCII characters | x-pb-reason: invalid-idempotency-key. |
| 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 | Signals from a registered source must be signed with that source's secret (x-pb-signature header missing) | x-pb-reason: source-signature-required. |
| 401 | Invalid webhook signature | HMAC mismatch. x-pb-reason: invalid-signature. |
| 401 | Webhook signature is outside the 300 second window | Timestamped signature older or newer than 300 seconds. x-pb-reason: signature-expired. |
| 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. |
| 403 | This signal source has been revoked | x-pb-reason: source-revoked. |
| 404 | Unknown tenant | x-pb-reason: unknown-tenant. |
| 409 | A request with this idempotency key is still being processed | x-pb-reason: idempotency-in-flight, Retry-After: 2. |
| 413 | Request body too large | Over the 64 KB cap. |
| 422 | Payload validation failed | Carries a details array of field errors. |
| 422 | This idempotency key was already used for a different request body... | x-pb-reason: idempotency-key-reused. |
| 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. |
| 500 | The signal source's secret could not be read | x-pb-reason: source-secret-unusable. |
| 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. |
| 503 | Signal source lookup failed | x-pb-reason: source-lookup-failed. Retry. |
| 503 | Idempotency store is temporarily unavailable... | x-pb-reason: idempotency-unavailable. Retry with the same key. |
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 | Counts matching revocation targets without changing them; records an audit event. A failed subsystem read returns 502. |
cascade_history | The tenant's cascade audit rows, newest first. limit defaults to 50 and is capped at 200. |
classify | Classifies signal_type, or up to 50 signal_types, exactly as cascade_execute would, and acts on nothing. Each result names the class and the decision: revoke, route_to_trust_engine or reject. |
get_model | The model in force: the hard and soft lists, a version derived from their contents, and the organization's soft-signal trust ladder, or the platform defaults when it has not set one. |
update_threshold | Sets the organization's trust ladder from full_at and read_only_at, or returns it to the defaults with reset: true. |
cascade_receipt | Returns the stored signed receipt of one executed cascade by its correlation_id, with the row ids its digests bind, whether the cascade was run here or started by an inbound CAEP event. Reads only. |
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
}Signed receipt. An executed hard-signal cascade (not a dry run) also returns executed_at, revoked_ids (the ids of the rows each subsystem changed, up to 1,000 per subsystem) and receipt. The receipt is a compact JWS with typ pb-cascade-receipt+jwt, signed with the organization's ES256 key (ES384 on the ML-DSA-87 tier), plus a detached ML-DSA proof over the same signing input at the organization's level: ML-DSA-65 by default, ML-DSA-87 on that tier. Its claims bind the correlation id, the signal, executed_at and, for each subsystem, the count of rows changed and a SHA-256 over their ids, sorted and joined by line feeds; the digest is null when more than 1,000 rows changed and the id list is incomplete. The same response names revoked_ids_complete per subsystem, false when that list was capped. Both public keys are in the organization's did:web document, so anyone holding the receipt can check it without PasskeyBridge, and receipt.verification spells out the steps. If the organization has no signing key yet, the first receipt provisions one, as credential issuance does.
The receipt is signed after the revocation has run, so a signing failure never undoes the cascade: receipt is null and receipt_error names the reason. Each receipt is also kept with the ids it binds, and cascade_receipt returns it later by the cascade's correlation id; receipt_stored says whether this one was kept, and when it is false the response holds the only copy. The audit row records the receipt's jti, its key ids and whether it was kept.
A hard-mapped inbound CAEP event runs the subject-scoped shadow-only branch and receives the same receipt format. Its delegates, negotiations and tunnels counts are zero; the audit metadata names those skipped subsystems. The response goes to the transmitter, so the organization retrieves a stored receipt through cascade_receipt. An empty subject scope is refused instead of expanding the action to the whole organization.
Trust ladder. Classification is the same for every organization and cannot be tuned: a hard signal always revokes. What an organization can set is the ladder an agent delegate climbs down on soft signals. At or above full_at a delegate keeps its full scopes, at or above read_only_at it keeps its read scopes, below that it is narrowed to verify, and below its own auto_revoke_below floor it is revoked. The platform defaults are 0.70 and 0.40. update_threshold takes both values with at most two decimal places and 0 < read_only_at < full_at < 1, answers with the new ladder and the previous one, and is recorded in the audit log as cascade_threshold_updated; each save raises the ladder's version, which get_model reports.
curl -X POST https://api.passkeybridge.io/v1/shield-cascade \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{"action":"update_threshold","tenant_id":"<tenant-id>","full_at":0.75,"read_only_at":0.45}'Errors. 400 for an unknown action, a missing tenant_id, a missing signal_type, a classify request with no type or more than 50, a ladder out of order or past two decimal places, or a cascade_receipt request whose correlation_id is not of the form pb-<hex>-<8 hex>; 403 plan_required below Enterprise; 402 payment_required when the subscription is unpaid; 404 when no receipt is stored for that correlation id; 502 Cascade execution failed when a subsystem write fails, and 502 when the organization's ladder or a stored receipt cannot be read, or the ladder cannot be saved. Confirm in Dashboard > Revocation, which lists each run and its counts and copies a stored receipt.
Signal ingest has its own inline path: organization-wide delegates and A2A negotiations, plus the subject's shadows, before playbook actions. The admin endpoint runs all four subsystems organization-wide. Inbound CAEP uses subject-scoped shadow freezing. The current path comparison lists their scope and failure behavior.
BLAST tunnels
`POST /v1/shield-blast` (Enterprise; API key with the ingest scope, or a tenant-admin session). Session tunnel lifecycle: X25519 key agreement, optional hybrid ML-KEM-768, 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. ttl_ms above 3,600,000 milliseconds is refused with 400. 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, a ttl_ms above one hour, or oversized plaintext; 403 plan_required below Enterprise; 403 missing scope; 404 when blast_encrypt cannot find the session; 409 when that session has been torn down; 410 when it has expired. 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; stored rounded up to a multiple of 10 |
capture_timestamp | string | ISO 8601, checked against the database clock with a 500 ms tolerance, then stored rounded to 100 ms |
os_platform | string | optional OS family: android, ios, windows, macos, linux, chromeos or other; any other value answers 400 NF08_PLATFORM_NOT_A_FAMILY |
credential_id, sdk_version, channels, metadata | mixed | optional provenance |
A device_model answers 400 NF08_DEVICE_MODEL_REFUSED: the model is one of the inputs to device_identifier_hash and is never sent on its own.
The server keys every digest field (user_hash, device_identifier_hash, fingerprint_hash, emi_spectral_hash, thermal_variance_hash, accelerometer_hash, network_jitter_hash) with HMAC-SHA-256 under the server-held pepper before storing it.
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, with NF08_DEVICE_MODEL_REFUSED for a device model and NF08_PLATFORM_NOT_A_FAMILY for a platform outside the families; 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; 500 NF08_BASELINE_FINGERPRINT_MISSING when the enrolment row names a fingerprint that is not in the table (Enrollment fingerprint is missing); 500 NF08_BASELINE_READ_FAILED when that read fails (Enrollment fingerprint could not be read). The response does not include the database message. 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 through the upsert_rp_config action of shield-admin-mutations (a tenant-admin session; there is no dashboard screen for it today); the function reads rp_id, its allowed origins and its credential policy from that row and revalidates them on every ceremony.
Allowed origins. allowed_origins holds 1 to 16 exact origins, compared as whole strings with clientDataJSON.origin. A web origin must be https, with no path, query, fragment, credentials or wildcard, and its host must be the rp_id or a subdomain of it; it is stored normalized (https://App.Example.com:443 becomes https://app.example.com). A native Android app that uses Credential Manager reports android:apk-key-hash: followed by the base64url SHA-256 of its signing certificate, without padding (43 characters), instead of a web origin. Since October 18, 2026 that form is accepted too, stored exactly as you send it (no lowercasing), and refused with apk_key_hash_malformed when it has any other length, = padding or characters outside base64url. Add one entry per signing certificate, for example both the Play app-signing key and the upload key, and list the same certificates in https://<rp_id>/.well-known/assetlinks.json with the delegate_permission/common.get_login_creds relation, because Android only lets the app use credentials of the rp_id when that file names it. A ceremony from an app whose origin is not in the list fails with 401 verification_failed, reason origin_mismatch. The rp_id check is unchanged: the app's ceremonies still use your domain as the RP ID.
Sandbox keys are refused here with 403 sandbox_not_supported.
User verification is required at both ceremonies. register_options and auth_options ask the authenticator for it (userVerification: "required"), and register_verify and auth_verify refuse a response whose authenticator data lacks the user-verification flag with 401 verification_failed, reason user_not_verified. The check runs on every ceremony rather than being recorded on the credential, so it holds for credentials enrolled or imported before October 1, 2026 as well, and user_verified in the auth_verify answer is always true.
Credential policy (opt-in). The relying-party configuration carries two optional fields, set through upsert_rp_config: require_device_bound (default false) and aaguid_allowlist (default none). With require_device_bound on, a backup-eligible (synced) credential is refused: at registration with 422 credential_policy_refused, reason backup_eligible; at assertion with 401 verification_failed, reason backup_eligible, which also covers a credential enrolled or imported before the policy was turned on; at import per record, reason credential_policy: backup_eligible, and a record that does not state backupEligible: false is refused with backup_eligibility_unknown. With an aaguid_allowlist (1 to 64 lowercase hyphenated AAGUIDs, never the all-zero one), register_options asks for direct attestation so the authenticator reports its real AAGUID, and a credential whose AAGUID is absent, all zeros or not listed is refused at the same three points with reason authenticator_not_allowed. The upsert that turns a policy on answers with policy_impact: how many credentials already stored under the relying party the policy would refuse at their next assertion (backup_eligible, backup_eligibility_unknown, authenticator_not_allowed); nothing is deleted. When an allowlist is set, registration, assertion and import also load the FIDO MDS3 table of contents and require its certificate chain to verify to the pinned root. A table that cannot be fetched or verified refuses the ceremony with mds_unverified (422 credential_policy_refused at registration, 401 verification_failed at assertion, credential_policy: mds_unverified at import). An AAGUID the verified table lists as REVOKED, USER_VERIFICATION_BYPASS, ATTESTATION_KEY_COMPROMISE, USER_KEY_REMOTE_COMPROMISE or USER_KEY_PHYSICAL_COMPROMISE is refused the same way with authenticator_revoked. An AAGUID the table does not list stays on the allowlist alone. A verified table is cached in the isolate for six hours. Trust boundary: the AAGUID is still read from authenticator data the library has checked against the attestation statement. The authenticator's own certificate chain is not checked against a vendor root, so a model claim is as strong as that statement plus the metadata status.
| 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 must be an opaque id from your own system, such as a UUID or an internal account number. A handle shaped like an email address, a phone number or an IP address, or one containing an email address, is refused at every action with 400 bad_request, issue code pii_shaped, and at import per record with reason external_user_handle_pii_shaped. The handle is keyed-hashed (HMAC-SHA-256 under a server-held pepper) before any lookup; PasskeyBridge stores the digest, a random WebAuthn user id and the credential public key, and never a name, display name or email. An imported webauthn_user_id that decodes to an email, phone, IP, a name (letters only, such as Alice Smith) or text containing the handle is refused per record with webauthn_user_id_pii_shaped. Audit rows for passkey actions keep the keyed IP digest but not the User-Agent. 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, user_not_verified, user_handle_mismatch, backup_eligible or authenticator_not_allowed; 422 credential_policy_refused with reason backup_eligible, backup_eligibility_unknown or authenticator_not_allowed when the relying party's credential policy refuses a verified registration; 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` (an API key of the tenant with vc_verify, 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 counts against the tenant's per-minute quota. 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, the intelligence worker and shield-agent-delegate do. Actions are record_activity, evaluate and evaluate_tenant; record_activity scores the delegate before it answers when the recorded result is not success. pg_cron calls evaluate_due every five minutes with the cron secret, and that secret reaches no other action.
`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.
One-request assurance
`POST /v1/shield-assurance` (API key for the tenant, or a tenant-admin session). Resolves the channels you include in one call: a hosted passkey assertion, a DPoP proof, a live carrier lookup, and the status of one verifiable credential. Omit a channel and it is reported not_requested. It does not enroll a passkey, bind a DPoP key, or accept a bring-your-own carrier record. record stays on shield-carrier-lookup, because that path checks a signature over the exact bytes it received.
The decision is pass only when every channel you named passed. A SIM swap and a credential that is not active are fail (HTTP 200, decision: "fail"). A provider or registry outage is unavailable (HTTP 503). Passkey and DPoP proofs are single use, and they run only after the carrier lookup and the credential read have answered, so an outage there does not spend either ceremony. When both are requested, DPoP runs first, and an unavailable DPoP leaves the passkey unspent. A passkey outage after DPoP has answered has already used that DPoP proof. A fail still spends a proof that ran. decision_ms is this request's own duration.
The outer call checks that the key is active for tenant_id and enforces the per-tenant quota. Each channel that runs is counted again by that channel's own handler. The passkey channel then requires passkey_rp and the DPoP channel requires dpop. The carrier channel is the same lookup shield-carrier-lookup runs, including the hosted-lookup add-on where that lookup requires one, and it forwards the normalized signal into ingest the same way. The number is not in the assurance answer. Credential status is active, revoked, suspended, expired or unknown.
| Channel | Object | What it runs |
|---|---|---|
passkey | the auth_verify body (rp_id, challenge_handle, response) | shield-passkey-rp auth_verify |
dpop | the verify body (dpop_proof, access_token, htm, htu) | shield-dpop verify |
carrier | the lookup body (provider optional). The number is the same field shield-carrier-lookup accepts | live lookup |
vc | credential_id | status of that credential |
curl -X POST https://api.passkeybridge.io/v1/shield-assurance \
-H "x-pb-api-key: pb_live_..." \
-H "Content-Type: application/json" \
-d '{"tenant_id":"<tenant-id>","passkey":{"rp_id":"example.com","challenge_handle":"<handle>","response":{}},"vc":{"credential_id":"urn:uuid:..."}}'{
"decision": "fail",
"channels": {
"passkey": { "state": "pass", "code": "verified", "http_status": 200 },
"dpop": { "state": "not_requested" },
"carrier": { "state": "not_requested" },
"vc": { "state": "fail", "code": "revoked", "credential_status": "revoked" }
}
}Errors. 400 when the body names no channel, a channel action other than the one above, a mismatched tenant_id, or a credential_id that is not an id; 401 or 403 when the key or session is rejected before any channel runs; 413 when the body exceeds 64 KB; 503 when a requested channel could not be answered.
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_identity_insights, sinch_number_lookup, vonage or twilio; any other value is refused, and a lookup that names none runs under your own registered account (the Vonage application first, then the Sinch project). vonage_identity_insights runs a SIM swap check under your own Vonage application (Settings, Carrier lookups); sinch_number_lookup runs Sinch Number Lookup under your own Sinch project (Settings, Sinch Number Lookup); vonage (Number Insight, which reports no SIM swap) and twilio run on PasskeyBridge's accounts and need the hosted carrier lookups add-on. 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 provider list, each with its capabilities and whose account it uses (credentials: tenant or platform) |
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_identity_insights"}'{
"status": "ok",
"signal": {
"provider": "vonage_identity_insights",
"phone_hash": "6b2f...",
"signal_type": "sim_swap_detected",
"risk_score": 0.8,
"carrier_name": null,
"carrier_type": null,
"ported": false,
"reachable": true,
"roaming": false,
"sim_swap_detected": true,
"sim_swap_age_hours": 2
},
"latency_ms": 640,
"recorded": false,
"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, a missing phone or an unknown provider; on vonage_identity_insights or sinch_number_lookup, or a lookup that names no provider, 400 for a number not in international format and 400 carrier_credentials_missing when the account it needs is not registered; 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; 402 addon_required for vonage or twilio without the hosted carrier lookups add-on, or 402 payment_required when the subscription is unpaid; 404 Unknown tenant; 502 Carrier lookup failed with a detail string that has any phone-shaped digit run redacted; 503 when the registered account could not be read. Your Vonage application and your Sinch project are registered under Settings, never through this endpoint; the credentials behind vonage and twilio are PasskeyBridge's own.
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.
Discovery needs no credentials. well_known (by tenant_id) and GET /v1/shield-sse-caep/ssf-configuration/{slug} return the organization's transmitter metadata, which a Shared Signals receiver also finds at https://api.passkeybridge.io/.well-known/ssf-configuration/v1/tenants/{slug}, the location SSF 1.0 derives from the issuer. GET /v1/shield-sse-caep/jwks/{slug} returns the organization's signing keys as a JSON Web Key Set.
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..."}'Inbound, the 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. A transmitter stream can also carry risk-level-change, which PasskeyBridge publishes on its own for every classified signal once the stream names it (CAEP outbound transmitter).
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 takes any of the six transmitter event types, 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` (Enterprise; 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; 403 with plan_required below Enterprise, or 402 payment_required on an Enterprise plan whose billing entitlement is revoked; 409 with code: REPLAY_DETECTED when the same attestation digest arrives twice within 5 minutes; 413 Payload too large; 500 Failed to store attestation or Plan verification failed. 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. It says whether a key was present. It does not return any part of the 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.