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.

Last reviewed September 16, 2026Fresh

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

StatusWhen it fires
200Success. Also an acknowledged-and-ignored Stripe event, and a duplicate security event token.
201Created: a SCIM user, a spatial-binding enrolment.
204SCIM deprovision or group delete.
302did:web resolution redirect to the document URL.
304StatusList2021 read whose If-None-Match matched the current ETag.
400Missing or malformed field, invalid JSON, unknown action, unsupported DID method, invalid Stripe signature.
401Missing or invalid credential; missing required ingest signature; a DPoP binding already consumed.
402payment_required (subscription unpaid) or addon_required (paid add-on not purchased).
403Missing scope, caller lacks the admin role, plan_required, direct call to the Supabase host, spatial anomaly detected, agent revoked or expired.
404Unknown tenant, unknown function at the edge, or a resource that is not this tenant's.
405Wrong method: POST-only functions, GET-only DID resolution and entropy receipts.
409Replay detected, credential already revoked, duplicate SCIM user, cross-reference or DPoP binding already exists, delegate already revoked.
410Expired shadow identity or expired session tunnel.
413Body 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.
422Semantically invalid: capture window exceeded, clock drift, schema mismatch, credentials not enabled for the tenant, reinstating a revoked credential.
429Per-IP rate limit, per-tenant quota, sandbox allotment or Starter monthly cap.
500Unexpected server failure. Check the function logs with the correlation id.
502Upstream failure: a carrier provider, a persistence write, a cascade subsystem, or the worker's own Upstream unreachable.
503Tenant 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.

  1. Method and body: 405 Method not allowed, 413 Request body too large, 400 Invalid request body, 400 Invalid JSON body.
  2. Schema: 422 Payload validation failed with a details array, one entry per field, for example event_type: Signal type contains invalid characters.
  3. subject_ref: 400 with x-pb-reason: invalid-subject-ref when the value is not 1 to 128 printable ASCII characters, or looks like an email address or a phone number.
  4. Tenant: 400 Missing tenant ID in path or x-pb-tenant-id header, 503 Tenant lookup failed, 404 Unknown tenant, 403 Tenant is suspended.
  5. Rate limit: 429 from the per-IP layer, then the per-tenant plan layer.
  6. 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.
  7. Signature: 401 Webhook signature required for this tenant (x-pb-signature header missing), 401 Invalid webhook signature.
  8. 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.
  9. 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

CodeHTTPEndpointMeaning
REPLAY_DETECTED409shield-a2a-handshakeThe 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_DETECTED409shield-sdk-attestationThe same attestation digest arrived twice within 5 minutes.
MTLS_FAILED403shield-a2a-handshakeCertificate verification failed. The body carries initiator_cert and responder_cert with the per-delegate reason.
NF08_TEMPORAL_WINDOW_EXCEEDED422shield-spatial-bindcapture_duration_ms exceeds the 100 ms maximum.
NF08_CLOCK_DRIFT422shield-spatial-bindThe capture timestamp is outside the 500 ms strict tolerance. The body carries drift_ms, server_time, tolerance_ms and stratum.
NF08_NO_BASELINE404shield-spatial-bindVerification with no enrolment for this user. Enrol first.
DPOP_BINDING_NOT_FOUND403shield-dpopNo live binding for this access token and key thumbprint.
DPOP_NONCE_REQUIRED400shield-dpopThe binding expects a nonce. A fresh dpop_nonce is returned in the body.
DPOP_REPLAY401shield-dpopThe binding was already consumed. Bindings are single use.
payment_required402plan-gated functions, shield-ingestThe purchased plan qualifies but billing revoked entitlement. The body carries billing_url.
plan_required403plan-gated functionsThe tenant's plan is below the requirement. The body carries required_plan, current_plan and upgrade_url.
addon_required402shield-predictThe paid add-on has not been purchased.
tenant_quota_exceeded429any function enforcing quotaThe 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-reasonStatusBody errorFix
edge-locked403Direct access denied. Use https://api.passkeybridge.io/v1/{function}.Call the /v1 host instead of the Supabase functions host.
missing-key401Missing authorization or x-pb-api-key headerSend x-pb-api-key or Authorization.
missing-auth401Missing authorization headerThe request took the session lane and carried no bearer token.
invalid-key401Invalid API keyNo active key row for this key and this tenant. Check x-pb-tenant-id.
invalid-jwt401Invalid auth tokenSession token expired or malformed.
not-tenant-admin403Tenant not found or user is not an adminThe signed-in user does not administer this tenant.
missing-scope403API key missing 'ingest' scopeMint a key with the scope, or use a key that has it. The message names the scope, or several joined by or.
quota-exceeded429tenant_quota_exceededPer-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 verify

If 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 messageHTTPCauseFix
Missing or invalid tenant_id, initiator_delegate_id, or responder_delegate_id (must be UUIDs)400A field is absent or is not a UUIDSend all three as UUIDs.
Agent cannot negotiate with itself400Same id for both delegatesUse two distinct delegates.
One or both delegates not found in this tenant404Wrong id or wrong tenantCheck the ids against Dashboard > Agents.
Both agents must be active to initiate negotiation400A delegate is revokedRe-create the revoked delegate.
Cannot attest—status is 'X', expected 'pending'400Wrong lifecycle phaseThe order is initiate, attest, negotiate.
Cannot negotiate—status is 'X', expected 'attested'400Attestation has not runCall attest first.
Cannot renew—status is 'X', expected 'active'400Only an active negotiation renewsRe-negotiate instead.
Negotiation not found404Unknown id, or another tenant'sCheck negotiation_id.
Certificate verification failed403MTLS_FAILED; the body names which side failedFix or remove the certificate pin.
Not a hard signal—no invalidation needed200A soft signal was sent to invalidateSoft 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 messageHTTPCauseFix
Missing subject_did—the DID of the credential subject400Field absentSend subject_did.
Missing credential_type—e.g. 'IdentityAttestation', 'CarrierVerification'400Field absentcredential_type is required; there is no default.
Verifiable Credentials not enabled for this tenant422vc_enabled is falseConfigure a credential provider in Dashboard > Identity.
Credential claims do not match registered schema422Schema validation failedThe body carries schema_type, schema_version and validation_errors.
No active VC provider configured422Verification has no provider rowComplete provider onboarding.
VC provider endpoint is not permitted422The configured endpoint failed the outbound guardUse a public HTTPS endpoint.
Credential already revoked409Revoking twiceCheck status first.
Cannot reinstate a revoked credential—revocation is permanent422reinstate on a revoked credentialIssue a new credential. Suspension is the reversible state.
Credential not found for this tenant404Wrong tenant or unknown idCheck the issuing tenant.
Batch limit exceeded: max 20 vp_tokens per request400Oversized batchSplit the batch.
Invalid presentation_definition400Structure rejectedThe body carries details.
Invalid issuer DID format. Expected did:web:passkeybridge.io:tenants:{slug}400Malformed issuer on a status list readUse 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 codeHTTPCauseFix
NF08_TEMPORAL_WINDOW_EXCEEDED422capture_duration_ms exceeds 100The atomic fingerprint capture must complete inside a 100 ms window. The body's detail gives the submitted duration and the maximum.
NF08_CLOCK_DRIFT422Capture timestamp outside the 500 ms strict toleranceSync against GET /v1/shield-ntp and resend. The body carries drift_ms, server_time, tolerance_ms, stratum and source.
NF08_NO_BASELINE404Verifying with no enrolment for this user hashCall action: "enroll" first.
Temporal window exceeded422Same as the first row; this is the error text beside the codeAs above.
action must be 'enroll' or 'verify'400Unknown actionOnly those two exist.
Invalid user_hash / fingerprint_hash / emi_spectral_hash / thermal_variance_hash / device_identifier_hash400Field missing or under 16 charactersAll five are required digests.
capture_duration_ms must be a positive number400Missing or non-numericSend the measured duration.
Missing x-pb-api-key header401No keyThis endpoint has no session lane.
Invalid or inactive API key401No active key row for this tenantCheck the key and tenant_id.
API key lacks 'spatial' scope403Scope missingMint a key with spatial.
Failed to store fingerprint record500Write failureRetry.
Failed to create spatial binding / verification binding500Write failed after the fingerprint was storedRetry; 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 wants x-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 read tenant_id from 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 are GET only.
  • Calling negotiate before attest, or reusing a nonce.
  • Treating a 200 with status: "warning" as a clean result.
  • Expecting x-pb-scope-warning: legacy-unscoped to be harmless: it means the key predates scope enforcement and should be reissued.

Related from the blog