Skip to main content
This page is organized by what you are seeing, not by error code. Find your symptom, read the cause, run the check. For the exhaustive code-by-code reference, see Errors. Two surfaces fail differently, so check you are on the right one. Census errors (everything with a code + class in the body) are below under Symptoms. Failures from your own Link route — the handler that @phosra/link mounts under /api/phosra/link/* — have no message and no class and are under Phosra Link symptoms.
Every check runs against the live sandbox at https://phosra-api-sandbox-production.up.railway.app — no key required for the reads below. Set it once:

First: decode any error in five seconds

Every error carries the same three fields. Read them in this order:
  1. code — the HTTP status. 4xx is your request; 5xx is ours.
  2. class(OCSS routes only) the machine failure class. Branch on this.
  3. failed_step(403 standing failures only) the exact check that rejected you.
Grab the X-Railway-Request-Id response header before you contact support — it lets us find your exact request in the logs.

401 vs 404: which one should I be getting?

The two most-confused failures. They mean opposite things, and the API keeps them strictly separate so you always know which side the problem is on. The trap: a signed route you call without a signature returns 401, not 404 — even if the path is nonsense. Authentication is checked before resource lookup, so a missing signature masks whether the resource exists at all.
Decide in one question: does this route require a request signature? If yes and you’re getting 401 → fix the signature (see below). If no, or your signature is already valid and you still get 404 → the id in your path is wrong; verify it character-for-character. A 404 is terminal — do not poll it back to life.

Symptoms

Class: signature_invalidThe route requires an RFC 9421 HTTP Message Signature and your request had none, had more than one, or the pair was malformed.Common causes
  • You sent a bearer token where a request signature is required. Data-plane writes (rule writes, confirmations, consent) are signed, not bearer-authed.
  • You sent Signature without its matching Signature-Input (or vice-versa).
  • A proxy stripped or duplicated the headers.
Fix
  1. Attach exactly one Signature-Input and one Signature header.
  2. Sign with the key_id the census published for you — the exact did#kid echoed in your self-register response body.
  3. Confirm your key is on the Trust List:
Reproduce the failure (no signature → 401):
Class: signature_invalidYour signature is well-formed, but the key_id you signed with is not the one published on the Trust List for your DID.Cause
  • You signed with did:ocss:you#some-kid but the census published did:ocss:you#2026-07 (the default is YYYY-MM).
  • You rotated your key and are signing with the old kid.
Fix — sign with the exact key_id from your registration response. When you self-register you can pin a bespoke kid; whatever the census echoes back is authoritative:
Class: (house — no class field)A validation error before the OCSS layer: a missing required field or a body that isn’t valid JSON.Fix
  • invalid_json: unexpected EOF → your JSON is truncated. Check quoting and that the body is complete.
  • did is required → include a did.
  • public_key_b64url is required → include your Ed25519 public key as a 43-char base64url string (no padding).
Minimal valid self-register:
Class: malformed (standing check step d)You wrote a rule with a rule_category that is not in the OCSS registry. The vocabulary is closed — the API rejects unknown slugs rather than coercing them.Fix — use a registered category. The full list is the rule reference; common ones: addictive_pattern_block, dm_restriction, age_gate, content_block_title, infinite_scroll_block.The same closed-vocabulary rule applies to age_band, harm_class, and method_class — an off-list value returns malformed, never a best-effort match.
Class: standing_failure or scope_failureread failed_step.Your request was cryptographically valid but you lack the authority for it. failed_step names the exact §6.2 check:For scope_failure, a consent attestation arrived for an app your DID does not operate — fix the app_ref so it names an app you run, or register that app.Sandbox shortcut — a self-registered provisional DID can mint a TEST consent for itself over a sandbox test child, so a consent-first write completes without a Phosra-side roster edit:
Class: not_foundThe connect endpoint resolves OAuth config for an accredited provider. A 404 means either the DID isn’t on the Trust List, or it’s registered but has no oauth_connect config seeded.Fix / diagnose
A 404 here is never a silent substitution — the census will not redirect an unknown DID to a nearby live provider. Verify the DID string exactly.Wrong-host check: a 404 on all DIDs (even did:ocss:loopline) usually means $BASE points at a non-census host — most often the control plane prodapi.phosra.com, which does not serve /providers/{did}/connect. Verify $BASE is an OCSS census (the sandbox host, or your assigned production census host — see Production Accreditation), not prodapi.
Class: (house — Conflict)Self-register is create-only. It cannot overwrite an existing Trust List entry — that guards against a squatter rebinding another party’s DID to a new key.Fix
  • Registering for the first time? Pick a DID that isn’t taken (the sandbox seeds 16+ reserved slugs like loopline, courier, beacon).
  • Rotating a key on a DID you already own? Self-register is not the path — use the key-rotation endpoint for your existing entry.
Class: replayYou reused an Idempotency-Key with a different payload than the first time it was seen. The key is permanently bound to its first body.Cause — a retry regenerated part of the payload: a fresh timestamp, a new nonce, or a re-serialized object whose key order changed.Fix
  1. Build the payload once, freeze it, then attach the key.
  2. On retry, resend the byte-identical payload with the same key.
  3. A faithful replay is a success — it returns 200 with the original receipt bytes and header OCSS-Replay: original. Treat that as done, not as a duplicate.
Class: nonconformantDistinct from signature_invalid. Your signature verified, but your deployment profile selects enforcement modes that fall outside the SUPPORTED set your Trust List entry declares (§9.4).Fix — reconcile the two sets: either narrow the modes your profile selects to what you published as SUPPORTED, or update your Trust List entry’s SUPPORTED set to include the modes you actually run. Never collapse this into a signature retry — the signature was fine.
Class: (house)You exceeded the per-key / per-window rate limit. Every response carries the live counters — you never have to guess the quota.Fix — read X-RateLimit-Reset (a Unix timestamp), sleep until then, retry once.
Only /api/v1/* responses carry these headers — the unauthenticated .well-known/ discovery reads are unmetered, so grep-ing them there returns nothing. Read your window off an API route.
Back off proactively when X-RateLimit-Remaining approaches zero rather than waiting for the 429.
When a provider rotates its key or a parent disconnects, there is a brief window where in-flight requests can race the change. The symptoms:
  • 401 signature_invalid right after a key rotation — you signed with the old kid while the census already published the new one. Fix: re-fetch the Trust List, pick up the current kid, re-sign. Do not retry with the stale key.
  • 404 not_found on a provider connect or profile right after a disconnect — the entry or endpoint was removed. Fix: treat 404 as terminal for that resource; do not poll it back to life. Re-run the connect ceremony to establish a fresh binding.
  • 403 standing_failure (authority_binding) right after a consent withdrawal — the standing that authorized your write is gone. Fix: stop writing for that subject; a withdrawn consent is final until re-granted.
Why this is safe by design: the Trust List is served with Cache-Control: max-age=300 and a strong ETag. Poll it with If-None-Match so you notice a rotation within one cache window instead of discovering it via a failed write:
Not an error. OCSS reads serve last-known-good and report staleness through a Receipt (§8.1 cl.4) rather than failing. There is deliberately no “stale” error class.How to tell how fresh a read is
  • Cache-Control: public, max-age=300 → the artifact is guaranteed fresh for 300s.
  • The strong ETag changes the instant the artifact does — poll with If-None-Match and a 304 proves nothing moved.
If you need a definitely-current view, issue a conditional GET; a 200 (not 304) means you now hold the latest bytes.
500 returns the fixed body {"error":"Internal Server Error","message":"internal error","code":500} — deliberately no class and no cause text (we never leak internals onto the wire).502 / 503 usually mean an upstream census is warming. Sandbox services auto-sleep to save cost and cold-start on the first request after idle.Fix
  1. Retry with exponential backoff (1s, 2s, 4s, 8s).
  2. If a 500 persists, capture the X-Railway-Request-Id and contact developers@phosra.com — it pins your exact request in the logs.
Class: (house)You used the wrong HTTP method for the route (e.g. POST to a GET-only path). The response has an empty body and an Allow header listing the accepted method.
Fix — switch to the method in Allow. Double-check you’re hitting the intended route; a trailing-slash or path-segment typo can land you on a different handler.

These come from your own Link route, not from the census, so none of the checks above apply to them. The full catalogue is Phosra Link route errors.
Two different gates return this code, and the one that catches most people has nothing to do with the Origin header.
  1. A missing Origin header is refused exactly like a mismatched one. The check is a strict inequality against your credential’s application.origin, and a request with no Origin is not equal to it. curl, Postman, and every server-side smoke test send none by default — so they all 403 while your credential is perfectly correct. Add it by hand:
  2. The request URL’s own origin is checked on every method, before any path or method dispatch, and it is derived from Host / X-Forwarded-Host. A proxy or container that does not forward the public host 403s everything — including GET …/link/callback, which is a top-level navigation with no Origin header, so gate (1) never even runs there.
Which one am I on? A 403 on the GET callback is always gate (2) — fix the forwarded host, not your CORS config. A 403 only on POST …/link/sessions from a tool is gate (1).GET …/link/sessions/{id} (status) is the only operation exempt from the header gate, which makes it a usable liveness probe.Then confirm the pinned origin:
A production credential can never work on http://localhost — production credentials are canonical-HTTPS-origin only. Sandbox credentials may carry http://localhost[:port] / http://127.0.0.1[:port].
That is the platform SDK (@phosra/gatekeeper), not the Link route. Bootstrap has not completed, and every protocol route answers the same opaque 503 until it does.Do not guessGET /api/phosra/status names the outstanding precondition directly, and every one of them is documented with its fix on Readiness.The two that are invisible from your own code:
  • Your own directory row must already exist and match exactly: directory_version: 1, name equal to your credential’s display name, and authorize/par/token/profiles URLs on your application origin. Missing row → PLATFORM_BOOTSTRAP_DIRECTORY_FAILED. Publish it with PATCH /api/v1/platforms/{did}/connect.
  • The census’s operating router must itself resolve active and accredited. If it does not, no platform can boot on that census — and nothing in your configuration can fix it. That surfaces as PLATFORM_BOOTSTRAP_TRUST_FAILED; report it to the census operator.

Still stuck?

1

Confirm the census is up

2

Confirm your identity is on the Trust List

3

Capture the request id and reach out

Grab X-Railway-Request-Id from the failing response and email developers@phosra.com with the request id, the code, the class, and (if present) the failed_step.For a Phosra Link failure there is no X-Railway-Request-Id — the route is yours, not the census’s. Send the correlationId your onDiagnostic hook logged, plus operation, subcode, lifecycleSubstep, and causeClass. That set is the whole picture; the HTTP body carries nothing else.

Error reference

The complete status-code and error-class tables, every field documented.

Authentication

API keys, WorkOS sessions, and RFC 9421 request signing.