Error Codes & Troubleshooting
The HTTP statuses, x-pb-reason headers and structured codes PasskeyBridge returns, what each one means, and how to trace a failed request.
Error response format
Every PasskeyBridge function answers errors as JSON. The shared helper writes {"error": "<human-readable message>"} and adds fields when the endpoint has more to say:
{
"error": "Temporal window exceeded",
"detail": "Capture duration 140ms exceeds maximum 100ms",
"code": "NF08_TEMPORAL_WINDOW_EXCEEDED"
}error is always present. code appears only where the endpoint defines a structured code (see the table two sections down). Other endpoints add details (an array of validation messages), issues (path and code pairs, no values), reason, hint, upgrade_url or billing_url.
Two surfaces differ. SCIM uses the RFC 7644 envelope with schemas, detail, status and sometimes scimType. The hosted passkey relying party answers {"error":"bad_request","issues":[{"path":"rp_id","code":"invalid_string"}]} and never echoes a submitted value.
Read the headers first. Auth and quota refusals carry x-pb-reason, which is stable and machine-readable while the message text is not. Every response proxied through api.passkeybridge.io also carries x-pb-debug-id, the Cloudflare ray id; quote it in a support ticket and it matches the access log one to one.
Handle errors in this order: HTTP status, then x-pb-reason, then code, then the error string.
HTTP status codes
| Status | When it fires |
|---|---|
200 | Success. Also an acknowledged-and-ignored Stripe event, and a duplicate security event token. |
201 | Created: a SCIM user, a spatial-binding enrolment. |
204 | SCIM deprovision or group delete. |
302 | did:web resolution redirect to the document URL. |
304 | StatusList2021 read whose If-None-Match matched the current ETag. |
400 | Missing or malformed field, invalid JSON, unknown action, unsupported DID method, invalid Stripe signature. |
401 | Missing or invalid credential; missing required ingest signature; a DPoP binding already consumed. |
402 | payment_required (subscription unpaid) or addon_required (paid add-on not purchased). |
403 | Missing scope, caller lacks the admin role, plan_required, direct call to the Supabase host, spatial anomaly detected, agent revoked or expired. |
404 | Unknown tenant, unknown function at the edge, or a resource that is not this tenant's. |
405 | Wrong method: POST-only functions, GET-only DID resolution and entropy receipts. |
409 | Replay detected, credential already revoked, duplicate SCIM user, cross-reference or DPoP binding already exists, delegate already revoked. |
410 | Expired shadow identity or expired session tunnel. |
413 | Body over the endpoint's cap: 64 KB for signals and small control payloads, 256 KB for larger JSON, 128 KB for the browser error reporter. |
422 | Semantically invalid: capture window exceeded, clock drift, schema mismatch, credentials not enabled for the tenant, reinstating a revoked credential. |
429 | Per-IP rate limit, per-tenant quota, sandbox allotment or Starter monthly cap. |
500 | Unexpected server failure. Check the function logs with the correlation id. |
502 | Upstream failure: a carrier provider, a persistence write, a cascade subsystem, or the worker's own Upstream unreachable. |
503 | Tenant lookup failed (x-pb-reason: tenant-lookup-failed), or a degraded deep health check. |
A 503 on a tenant lookup is deliberately distinct from a 404: the tenant may well exist and the query failed. Retry a 503; do not retry a 404.
Signal ingest errors
Signal ingest has the largest error surface on the platform, and its refusals are ordered. Knowing the order tells you what already passed.
- Method and body:
405 Method not allowed,413 Request body too large,400 Invalid request body,400 Invalid JSON body. - Schema:
422 Payload validation failedwith adetailsarray, one entry per field, for exampleevent_type: Signal type contains invalid characters. subject_ref:400withx-pb-reason: invalid-subject-refwhen the value is not 1 to 128 printable ASCII characters, or looks like an email address or a phone number.- Tenant:
400 Missing tenant ID in path or x-pb-tenant-id header,503 Tenant lookup failed,404 Unknown tenant,403 Tenant is suspended. - Rate limit:
429from the per-IP layer, then the per-tenant plan layer. - Credential:
401 Missing x-pb-api-key header,401 Invalid API key,403 API key missing 'ingest' scope,403 Tenant ID header does not match path tenant. In test mode:401 Test mode requires authorization header,401 Invalid auth token,403 Tenant not found or user is not an admin. - Signature:
401 Webhook signature required for this tenant (x-pb-signature header missing),401 Invalid webhook signature. - Allotment:
429 Sandbox and test-mode signal limit reached (1000 per 30 days). Use a live (non-sandbox) API key for production traffic.,429 Starter plan monthly signal limit reached (100). Upgrade at /pricing to keep processing signals.,402 Monthly signal allotment reached (100) while your subscription is unpaid. - Persistence:
502 Failed to persist signal event,500 Usage lookup failed.
A `200` can still report trouble. status: "warning" means one or more playbook actions failed; actions_failed_count says how many, and the Events tab shows each action's status. result: "no_matching_playbook" means the signal was stored and no playbook matched its event type.
The correlation id is generated by the function. An inbound x-pb-correlation-id header is not read on this path, so quote the correlation_id from the response body rather than the one you sent.
Not every function is action-routed. Signal ingest routes on event_type, SCIM and support are REST-shaped, and DID resolution and entropy receipts are GET only. Sending {"action": ...} to those does nothing.
Structured error codes
| Code | HTTP | Endpoint | Meaning |
|---|---|---|---|
REPLAY_DETECTED | 409 | shield-a2a-handshake | The nonce was already consumed, the timestamp is outside the 5-minute window, or a mutating action arrived without a nonce. Generate a fresh nonce and a current timestamp. |
REPLAY_DETECTED | 409 | shield-sdk-attestation | The same attestation digest arrived twice within 5 minutes. |
MTLS_FAILED | 403 | shield-a2a-handshake | Certificate verification failed. The body carries initiator_cert and responder_cert with the per-delegate reason. |
NF08_TEMPORAL_WINDOW_EXCEEDED | 422 | shield-spatial-bind | capture_duration_ms exceeds the 100 ms maximum. |
NF08_CLOCK_DRIFT | 422 | shield-spatial-bind | The capture timestamp is outside the 500 ms strict tolerance. The body carries drift_ms, server_time, tolerance_ms and stratum. |
NF08_NO_BASELINE | 404 | shield-spatial-bind | Verification with no enrolment for this user. Enrol first. |
DPOP_BINDING_NOT_FOUND | 403 | shield-dpop | No live binding for this access token and key thumbprint. |
DPOP_NONCE_REQUIRED | 400 | shield-dpop | The binding expects a nonce. A fresh dpop_nonce is returned in the body. |
DPOP_REPLAY | 401 | shield-dpop | The binding was already consumed. Bindings are single use. |
payment_required | 402 | plan-gated functions, shield-ingest | The purchased plan qualifies but billing revoked entitlement. The body carries billing_url. |
plan_required | 403 | plan-gated functions | The tenant's plan is below the requirement. The body carries required_plan, current_plan and upgrade_url. |
addon_required | 402 | shield-predict | The paid add-on has not been purchased. |
tenant_quota_exceeded | 429 | any function enforcing quota | The per-tenant per-minute quota. The body carries scope, limit and retry_after_seconds. |
Check code before parsing error. Where there is no code, the pair of HTTP status and x-pb-reason is the stable signal.
Rate limit responses
Three different 429 bodies exist, and they mean different things.
Per-IP and per-tenant plan limit. Two layers, an in-isolate counter and a database-backed one, both keyed by function, tenant and a keyed digest of the source IP.
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" }X-RateLimit-Layer is l1 when the in-isolate counter refused and l2 when the distributed counter did. X-RateLimit-Degraded: true means the distributed check could not be consulted, so the verdict was made per isolate.
Known reporting defect. The pre-authentication refusal on signal ingest uses the simple helper, which hard-codes X-RateLimit-Limit: 60 while the limit actually applied is 1,200 per minute per IP. The refusal is correct; the header understates it. Use Retry-After, which is right in every shape.
Per-tenant quota. A different body and its own reason header:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 3000
X-RateLimit-Remaining: 0
X-RateLimit-Scope: minute
x-pb-reason: quota-exceeded
{ "error": "tenant_quota_exceeded", "scope": "minute", "limit": 3000, "retry_after_seconds": 60 }Allotment caps. Sandbox keys and x-pb-test-mode requests share 1,000 signals per rolling 30 days and refuse with x-pb-reason: sandbox-cap. Starter tenants refuse past 100 signals in the calendar month with an upgrade message and no reason header.
Cloudflare. A single IP over 1,200 requests per minute per data centre is blocked at the edge for 60 seconds. That response never carries x-pb-reason, which is how you tell it from an application refusal.
Honour Retry-After as the minimum wait. The window is fixed, so retrying sooner earns another 429. Full detail is in Rate limits and quotas.
Authentication and authorization errors
The shared auth module stamps a reason header on every refusal. Key off the header.
x-pb-reason | Status | Body error | Fix |
|---|---|---|---|
edge-locked | 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | Call the /v1 host instead of the Supabase functions host. |
missing-key | 401 | Missing authorization or x-pb-api-key header | Send x-pb-api-key or Authorization. |
missing-auth | 401 | Missing authorization header | The request took the session lane and carried no bearer token. |
invalid-key | 401 | Invalid API key | No active key row for this key and this tenant. Check x-pb-tenant-id. |
invalid-jwt | 401 | Invalid auth token | Session token expired or malformed. |
not-tenant-admin | 403 | Tenant not found or user is not an admin | The signed-in user does not administer this tenant. |
missing-scope | 403 | API key missing 'ingest' scope | Mint a key with the scope, or use a key that has it. The message names the scope, or several joined by or. |
quota-exceeded | 429 | tenant_quota_exceeded | Per-tenant quota. |
Endpoints with their own inline check word it differently for the same condition:
{ "error": "Missing x-pb-api-key header" } // 401, shield-ingest, shield-vc-verify
{ "error": "Invalid or inactive API key" } // 401, shield-spatial-bind
{ "error": "API key lacks 'spatial' scope" } // 403, shield-spatial-bind
{ "error": "API key missing 'vc_verify' scope" } // 403, shield-vc-present
{ "error": "Invalid SCIM bearer token" } // 401, shield-scim
{ "error": "API key does not have 'scim' scope" } // 403, shield-scim
{ "error": "Unauthorized—missing Bearer token" } // 401, shield-a2a-handshake
{ "error": "Forbidden—not a member of this tenant" } // 403, shield-a2a-handshake
{ "error": "Only admins can create agent delegates" } // 403, shield-agent-delegate
{ "error": "Unknown or revoked agent" } // 403, agent verify
{ "error": "Agent delegation has expired" } // 403, agent verify
{ "error": "Agent not authorized for scope: ingest" } // 403, agent verifyIf a `401` surprises you, check three things. The header is x-pb-api-key, not Authorization (SCIM and Okta hooks are the two exceptions, and they want the key as a bearer token). The key starts with pb_live_ or pb_test_. The key has not been revoked in Dashboard > API keys, and x-pb-tenant-id names the tenant that owns it.
A `403` with no `x-pb-reason` on a proxied call is tagged upstream-auth by the worker. That means the function refused and did not classify the refusal itself.
A2A handshake errors
| Error message | HTTP | Cause | Fix |
|---|---|---|---|
| Missing or invalid tenant_id, initiator_delegate_id, or responder_delegate_id (must be UUIDs) | 400 | A field is absent or is not a UUID | Send all three as UUIDs. |
| Agent cannot negotiate with itself | 400 | Same id for both delegates | Use two distinct delegates. |
| One or both delegates not found in this tenant | 404 | Wrong id or wrong tenant | Check the ids against Dashboard > Agents. |
| Both agents must be active to initiate negotiation | 400 | A delegate is revoked | Re-create the revoked delegate. |
| Cannot attest—status is 'X', expected 'pending' | 400 | Wrong lifecycle phase | The order is initiate, attest, negotiate. |
| Cannot negotiate—status is 'X', expected 'attested' | 400 | Attestation has not run | Call attest first. |
| Cannot renew—status is 'X', expected 'active' | 400 | Only an active negotiation renews | Re-negotiate instead. |
| Negotiation not found | 404 | Unknown id, or another tenant's | Check negotiation_id. |
| Certificate verification failed | 403 | MTLS_FAILED; the body names which side failed | Fix or remove the certificate pin. |
| Not a hard signal—no invalidation needed | 200 | A soft signal was sent to invalidate | Soft signals go through the trust engine. |
Replay protection. initiate, attest, negotiate and renew each require a fresh nonce and a timestamp. status and lookup require neither. invalidate is service-to-service only and is refused with 403 Forbidden—service authentication required for anyone else.
REPLAY_DETECTED covers three distinct failures, so read the error string alongside the code: a nonce already consumed (Nonce already consumed—replay detected), a mutating action with no nonce (Replay protection required: nonce is mandatory for '<action>' action), and a timestamp outside the 5-minute window (Clock drift <n>ms (client ahead) exceeds 300000ms tolerance). Only the first is an actual replay; the other two are client bugs.
mTLS diagnostics name each side separately:
{
"error": "Certificate verification failed",
"code": "MTLS_FAILED",
"initiator_cert": "Presented certificate fingerprint does not match any pinned certificate",
"responder_cert": "No certificate pins configured (mTLS not enforced)"
}Inspecting a negotiation. action: "status" returns the current state, the combined trust coefficient, the derived limits, cache_valid and, when re-attestation is needed, re_attest_reason.
Verifiable credential errors
| Error message | HTTP | Cause | Fix |
|---|---|---|---|
| Missing subject_did—the DID of the credential subject | 400 | Field absent | Send subject_did. |
| Missing credential_type—e.g. 'IdentityAttestation', 'CarrierVerification' | 400 | Field absent | credential_type is required; there is no default. |
| Verifiable Credentials not enabled for this tenant | 422 | vc_enabled is false | Configure a credential provider in Dashboard > Identity. |
| Credential claims do not match registered schema | 422 | Schema validation failed | The body carries schema_type, schema_version and validation_errors. |
| No active VC provider configured | 422 | Verification has no provider row | Complete provider onboarding. |
| VC provider endpoint is not permitted | 422 | The configured endpoint failed the outbound guard | Use a public HTTPS endpoint. |
| Credential already revoked | 409 | Revoking twice | Check status first. |
| Cannot reinstate a revoked credential—revocation is permanent | 422 | reinstate on a revoked credential | Issue a new credential. Suspension is the reversible state. |
| Credential not found for this tenant | 404 | Wrong tenant or unknown id | Check the issuing tenant. |
| Batch limit exceeded: max 20 vp_tokens per request | 400 | Oversized batch | Split the batch. |
| Invalid presentation_definition | 400 | Structure rejected | The body carries details. |
| Invalid issuer DID format. Expected did:web:passkeybridge.io:tenants:{slug} | 400 | Malformed issuer on a status list read | Use the documented format. |
Status list reads. GET /v1/shield-vc-status?issuer=<did>&type=statuslist answers 400 for a malformed issuer DID. An issuer that is well-formed but whose slug matches no tenant also answers 400 with the same message, because the lookup and the format check share one failure path. A single credential lookup for an unknown id answers 200 with status: "unknown" rather than 404.
Expiry. expiration_days defaults to 90 and is capped by the registered schema's maximum lifetime when one exists, so a credential can come back with a shorter life than requested.
Issuance latency. Post-quantum signing dominates the issue path. The response carries latency_ms so you can see it per credential.
Spatial binding errors
| Error or code | HTTP | Cause | Fix |
|---|---|---|---|
NF08_TEMPORAL_WINDOW_EXCEEDED | 422 | capture_duration_ms exceeds 100 | The atomic fingerprint capture must complete inside a 100 ms window. The body's detail gives the submitted duration and the maximum. |
NF08_CLOCK_DRIFT | 422 | Capture timestamp outside the 500 ms strict tolerance | Sync against GET /v1/shield-ntp and resend. The body carries drift_ms, server_time, tolerance_ms, stratum and source. |
NF08_NO_BASELINE | 404 | Verifying with no enrolment for this user hash | Call action: "enroll" first. |
| Temporal window exceeded | 422 | Same as the first row; this is the error text beside the code | As above. |
| action must be 'enroll' or 'verify' | 400 | Unknown action | Only those two exist. |
| Invalid user_hash / fingerprint_hash / emi_spectral_hash / thermal_variance_hash / device_identifier_hash | 400 | Field missing or under 16 characters | All five are required digests. |
| capture_duration_ms must be a positive number | 400 | Missing or non-numeric | Send the measured duration. |
| Missing x-pb-api-key header | 401 | No key | This endpoint has no session lane. |
| Invalid or inactive API key | 401 | No active key row for this tenant | Check the key and tenant_id. |
| API key lacks 'spatial' scope | 403 | Scope missing | Mint a key with spatial. |
| Failed to store fingerprint record | 500 | Write failure | Retry. |
| Failed to create spatial binding / verification binding | 500 | Write failed after the fingerprint was stored | Retry; the fingerprint row already exists. |
A failed verification answers `403` by design. When the anomaly score reaches 0.45 the response is 403 with status: "anomaly_detected", passed: false, the score and the reasons. A clean verification is 200 with status: "verified". Both outcomes store a binding row, so both appear in Dashboard > Device bindings.
Anomaly scoring adds 0.8 for a device identifier that does not match the baseline, 0.5 when the EMI and thermal digests are both identical to the baseline (an exact replay), and 0.4 when the network jitter digest is identical (a jitter replay). A fresh capture from the enrolled device scores 0.
Debugging tips
1. Quote the correlation id from the response. Signal ingest generates its own correlation_id in the shape pb-<timestamp>-<random> and returns it. It threads through the trust engine, the intelligence worker and the audit log. An inbound x-pb-correlation-id header is not read on the ingest path.
2. Quote the debug id for anything proxied. Every response through api.passkeybridge.io carries x-pb-debug-id, the Cloudflare ray id.
3. Confirm your headers reach the edge. POST https://api.passkeybridge.io/v1/_debug/echo answers without calling any function and reports what arrived, including whether a key was present and the first 8 characters of it. If the key reached the edge and you still get 401, the tenant id is the usual culprit.
4. Know which headers the worker forwards. Eighteen names are copied onto the upstream request, x-pb-signature, x-shield-signature, dpop and if-none-match among them, so a signed ingest request, a DPoP proof and a conditional status-list read all reach the function through the public host. Anything outside that list is dropped before the function runs; the full list is in Edge-first signal verification.
5. Read the audit log. Dashboard > Audit log records every significant action with result set to success, partial or failure, the actor type (user, api_key, system) and the resource. Filter by resource type and id to trace a lifecycle.
6. Read the event row. Dashboard > Events shows the decision path time, the full response time, the playbook result and every action with its own status and latency. Test-mode and sandbox events carry a Test badge and are excluded by the live filter.
7. Common integration mistakes.
- Sending
Authorization: Bearer pb_live_...to a function that wantsx-pb-api-key. SCIM and Okta hooks are the only two that want the key as a bearer token. - Omitting
x-pb-tenant-id. Most key-authenticated endpoints need it, and the ones that readtenant_idfrom the body say so. - Calling the Supabase functions host directly and getting
403 Direct access denied. - Assuming every function is action-routed. Signal ingest routes on
event_type; SCIM and support are REST; DID resolution and entropy receipts areGETonly. - Calling
negotiatebeforeattest, or reusing a nonce. - Treating a
200withstatus: "warning"as a clean result. - Expecting
x-pb-scope-warning: legacy-unscopedto be harmless: it means the key predates scope enforcement and should be reissued.