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:code— the HTTP status.4xxis your request;5xxis ours.class— (OCSS routes only) the machine failure class. Branch on this.failed_step— (403 standing failures only) the exact check that rejected you.
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.401 signature_invalid | 404 not_found | |
|---|---|---|
| The route | exists, and requires you to prove who you are | may or may not exist — the resource behind it doesn’t |
| What’s wrong | your request signature (missing, malformed, wrong key_id) | the id in the path (a DID / rule / endpoint that isn’t there) |
| Re-signing helps? | Yes — fix the signature and retry | No — the thing isn’t there; a retry returns 404 forever |
| Leaks existence? | No — you get 401 before any lookup | Yes, deliberately: 404 means “genuinely absent,” never a silent redirect to a nearby live resource |
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.
Symptoms
401 — 'exactly one Signature-Input and one Signature header are required'
401 — 'exactly one Signature-Input and one Signature header are required'
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
Signaturewithout its matchingSignature-Input(or vice-versa). - A proxy stripped or duplicated the headers.
- Attach exactly one
Signature-Inputand oneSignatureheader. - Sign with the
key_idthe census published for you — the exactdid#kidechoed in your self-register response body. - Confirm your key is on the Trust List:
401 — 'unknown key_id' / signature verifies locally but the census rejects it
401 — 'unknown key_id' / signature verifies locally but the census rejects it
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-kidbut the census publisheddid:ocss:you#2026-07(the default isYYYY-MM). - You rotated your key and are signing with the old
kid.
key_id from your registration response. When you
self-register you can pin a bespoke kid; whatever the census echoes back is
authoritative:400 — 'did is required' / 'public_key_b64url is required' / 'invalid_json'
400 — 'did is required' / 'public_key_b64url is required' / 'invalid_json'
class field)A validation error before the OCSS layer: a missing required field or a body that
isn’t valid JSON.Fixinvalid_json: unexpected EOF→ your JSON is truncated. Check quoting and that the body is complete.did is required→ include adid.public_key_b64url is required→ include your Ed25519 public key as a 43-char base64url string (no padding).
400 — 'unregistered rule_category slug: the OCSS rule vocabulary is closed'
400 — 'unregistered rule_category slug: the OCSS rule vocabulary is closed'
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.403 — signature is valid but the write is refused
403 — signature is valid but the write is refused
standing_failure or scope_failure — read failed_step.Your request was cryptographically valid but you lack the authority for it.
failed_step names the exact §6.2 check:failed_step | What it means | Fix |
|---|---|---|
authority_binding | No standing binds your DID to this child/subject | Present a consent attestation or grant for the subject first |
scope | The category is outside your standing’s granted scope | Write only in-scope categories, or widen the standing |
unregistered_slug | The rule_category slug is not registered | Use a registered category |
band_exceeds_tier | Your accreditation tier can’t reach that enforcement band | Raise your tier, or write at a permitted band |
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:404 — 'provider not found' on /providers/{did}/connect
404 — 'provider not found' on /providers/{did}/connect
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 / diagnose409 — 'did_already_registered' on self-register
409 — 'did_already_registered' on self-register
- 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.
409 — 'replay' on a write I retried
409 — 'replay' on a write I retried
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- Build the payload once, freeze it, then attach the key.
- On retry, resend the byte-identical payload with the same key.
- A faithful replay is a success — it returns
200with the original receipt bytes and headerOCSS-Replay: original. Treat that as done, not as a duplicate.
422 — 'nonconformant': signature valid but the profile is rejected
422 — 'nonconformant': signature valid but the profile is rejected
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.429 — 'Too Many Requests'
429 — 'Too Many Requests'
X-RateLimit-Reset (a Unix timestamp), sleep until then, retry once./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.X-RateLimit-Remaining approaches zero rather than
waiting for the 429.Disconnect / rotation races — a call that worked a moment ago now 401s or 404s
Disconnect / rotation races — a call that worked a moment ago now 401s or 404s
401 signature_invalidright after a key rotation — you signed with the oldkidwhile the census already published the new one. Fix: re-fetch the Trust List, pick up the currentkid, re-sign. Do not retry with the stale key.404 not_foundon a providerconnector 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.
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:A read looks stale — am I getting old data?
A read looks stale — am I getting old data?
Cache-Control: public, max-age=300→ the artifact is guaranteed fresh for 300s.- The strong
ETagchanges the instant the artifact does — poll withIf-None-Matchand a304proves nothing moved.
200 (not 304)
means you now hold the latest bytes.500 / 502 / 503 — a server error
500 / 502 / 503 — a server error
{"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- Retry with exponential backoff (1s, 2s, 4s, 8s).
- If a 500 persists, capture the
X-Railway-Request-Idand contact developers@phosra.com — it pins your exact request in the logs.
405 — 'Method Not Allowed' with an empty body
405 — 'Method Not Allowed' with an empty body
POST to a GET-only path). The
response has an empty body and an Allow header listing the accepted method.Allow. Double-check you’re hitting the intended
route; a trailing-slash or path-segment typo can land you on a different handler.Still stuck?
Capture the request id and reach out
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.