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.
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:
export BASE=https://phosra-api-sandbox-production.up.railway.app

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.
# Pretty-print any error and its class
curl -s "$BASE/api/v1/providers/did:ocss:nope/connect" | python3 -m json.tool
# {
#   "error": "Not Found",
#   "message": "provider not found",
#   "code": 404,
#   "class": "not_found"
# }
Grab the X-Railway-Request-Id response header before you contact support — it lets us find your exact request in the logs.
curl -s -D - -o /dev/null "$BASE/health" | grep -i x-railway-request-id

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_invalid404 not_found
The routeexists, and requires you to prove who you aremay or may not exist — the resource behind it doesn’t
What’s wrongyour 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 retryNo — the thing isn’t there; a retry returns 404 forever
Leaks existence?No — you get 401 before any lookupYes, deliberately: 404 means “genuinely absent,” never a silent redirect to a nearby live resource
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.
# No signature on a signed route -> 401 (the id is never even looked up)
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE/api/v1/enforcement-confirmations" \
  -H "Content-Type: application/json" -d '{}'
# 401

# An UNSIGNED read route with a bogus id -> 404 (resource lookup runs)
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/api/v1/providers/did:ocss:nope/connect"
# 404
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:
# The signed Trust List carries an `entries` array — find YOUR DID in it.
# (The census serves `document` as a JSON object here; older builds stringified
#  it, so fall back to json.loads if needed.)
curl -s "$BASE/.well-known/ocss/trust-list" \
  | python3 -c "import sys,json; d=json.load(sys.stdin); doc=d['document']; doc=json.loads(doc) if isinstance(doc,str) else doc; e=doc['entries']; print(len(e),'entries'); print('mine:', any('did:ocss:acme-demo' in x.get('did','') for x in e))"
# e.g. 133 entries  — the live count grows as providers accredit; yours will differ.
Reproduce the failure (no signature → 401):
curl -s -X POST "$BASE/api/v1/enforcement-confirmations" \
  -H "Content-Type: application/json" -d '{}'
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:
curl -s -X POST "$BASE/api/v1/advisors/self-register" \
  -H "Content-Type: application/json" \
  -d '{"did":"did:ocss:acme-demo-'$(date +%s)'","public_key_b64url":"jDWZfB70DM8B3ZnPG8FQ0tnanqLgwJX4ZMr_E1Ugv3w"}' \
  | python3 -m json.tool
# -> "key_id": "did:ocss:acme-demo-...#2026-07"  <-- sign with THIS
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:
curl -s -X POST "$BASE/api/v1/advisors/self-register" \
  -H "Content-Type: application/json" \
  -d '{"did":"did:ocss:acme-demo-'$(date +%s)'","public_key_b64url":"jDWZfB70DM8B3ZnPG8FQ0tnanqLgwJX4ZMr_E1Ugv3w"}'
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:
failed_stepWhat it meansFix
authority_bindingNo standing binds your DID to this child/subjectPresent a consent attestation or grant for the subject first
scopeThe category is outside your standing’s granted scopeWrite only in-scope categories, or widen the standing
unregistered_slugThe rule_category slug is not registeredUse a registered category
band_exceeds_tierYour accreditation tier can’t reach that enforcement bandRaise your tier, or write at a permitted band
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:
POST /api/v1/sandbox/consent-attestations   (signed; PHOSRA_ENV=sandbox only)
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
# Accredited + configured -> 200 with authorize/token/profiles URLs
curl -s "$BASE/api/v1/providers/did:ocss:loopline/connect" | python3 -m json.tool

# Registered but NO connect config -> 404 (this is the load-bearing
# 'accredited-but-unconfigured' case, e.g. courier in the sandbox)
curl -s "$BASE/api/v1/providers/did:ocss:courier/connect"
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.
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.
# First call -> 200; identical DID again -> 409 Conflict
DID="did:ocss:dup-demo-$(date +%s)"
curl -s -o /dev/null -w "first:  %{http_code}\n" -X POST "$BASE/api/v1/advisors/self-register" \
  -H "Content-Type: application/json" \
  -d "{\"did\":\"$DID\",\"public_key_b64url\":\"jDWZfB70DM8B3ZnPG8FQ0tnanqLgwJX4ZMr_E1Ugv3w\"}"
curl -s -o /dev/null -w "second: %{http_code}\n" -X POST "$BASE/api/v1/advisors/self-register" \
  -H "Content-Type: application/json" \
  -d "{\"did\":\"$DID\",\"public_key_b64url\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}"
# first:  200
# second: 409
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.
# See your current window on any /api/v1 route (this GET needs no key).
curl -s -D - -o /dev/null "$BASE/api/v1/platforms" | grep -i x-ratelimit
# x-ratelimit-limit: 100
# x-ratelimit-remaining: 90
# x-ratelimit-reset: 1783311960
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:
ETAG=$(curl -s -D - -o /dev/null "$BASE/.well-known/ocss/trust-list" \
  | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
curl -s -o /dev/null -w "changed? %{http_code}\n" \
  -H "If-None-Match: $ETAG" "$BASE/.well-known/ocss/trust-list"
# 304 = unchanged, keep your cached keys; 200 = re-read, a key moved
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.
curl -s -D - -o /dev/null -X POST "$BASE/api/v1/providers/did:ocss:loopline/connect" \
  | grep -i '^allow:'
# allow: GET      (read routes also advertise HEAD)
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.

Still stuck?

1

Confirm the census is up

curl -s "$BASE/health"          # {"status":"ok"}
2

Confirm your identity is on the Trust List

curl -s "$BASE/.well-known/ocss/trust-list" | python3 -m json.tool | head
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.

Error reference

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

Authentication

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