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.
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, 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: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 / diagnosedid: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.409 — '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.Phosra Link symptoms
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.Any Link failure at all — where do I even look?
Any Link failure at all — where do I even look?
{"error":{"code","correlation_id"}} — no message, no
class. A failed callback is worse: it renders an HTML page (“We couldn’t finish
connecting”) with no code and no correlation id at all. Neither surface tells you
what went wrong, deliberately.The cause exists in exactly one place: the onDiagnostic hook on your Golden
server config. It is optional, so a config copied from a quickstart often omits it —
and then a failure is genuinely unrecoverable information.subcode (which step), lifecycleSubstep (which durable substep), and
causeClass (whose bug). Log correlationId — it is the only identifier tying the
failure to anything, and the one to quote to support. Full field reference:
onDiagnostic.403 ORIGIN_REJECTED — but I checked, the origins match
403 ORIGIN_REJECTED — but I checked, the origins match
Origin header.-
A missing
Originheader is refused exactly like a mismatched one. The check is a strict inequality against your credential’sapplication.origin, and a request with noOriginis 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: -
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 — includingGET …/link/callback, which is a top-level navigation with noOriginheader, so gate (1) never even runs there.
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:http://localhost — production
credentials are canonical-HTTPS-origin only. Sandbox credentials may carry
http://localhost[:port] / http://127.0.0.1[:port].The Link dialog says 'Try again' on a failure that can never succeed
The Link dialog says 'Try again' on a failure that can never succeed
createSession adapter is throwing away the error code.The dialog has an honest branch for permanent configuration faults — “This app isn’t
set up for this address”, with Close as the only button. It selects that branch
only when the rejection your adapter throws carries a string code equal to
"ORIGIN_REJECTED" (directly, or up to four cause links deep). A bare
throw new Error("Unable to start Phosra Link") carries no code, so it falls
through to the generic retryable copy and a Try again button that cannot work.Fix — lift error.code off the response body and attach it to what you throw.
The complete adapter is in
ORIGIN_REJECTED.401 UNAUTHENTICATED on the Link callback — only in local dev
401 UNAUTHENTICATED on the Link callback — only in local dev
http://localhost shares one cookie jar, so
if your provider app and the platform app use the same session cookie name,
signing in at the platform evicts the parent’s session on your provider. The
parent finishes the platform leg, the browser returns to GET …/link/callback, your
authenticate(request) adapter finds nothing, and the route answers 401.Nothing about your Link configuration is wrong. Fix — give each app a distinct
cookie name locally:LINK_OPERATION_FAILED and I have no idea which step broke
LINK_OPERATION_FAILED and I have no idea which step broke
lifecycleSubstep from onDiagnostic. The two that bite first:save_connection/record_evidence— your database refused a write. Almost always a package schema migration that was not re-run after an SDK upgrade. Runlink.migrate()(or applyprintLinkSchema()), then retry.deliver_rules— delivery to the platform receiver failed. Check the receiver’s allowlist for your provider DID.
causeClass: anything starting golden_directory_ is a trust/directory
problem, not your adapter. type_error and error mean an exception escaped your
own adapter code.Every /api/phosra/* route returns 503 PHOSRA_NOT_READY
Every /api/phosra/* route returns 503 PHOSRA_NOT_READY
@phosra/gatekeeper), not the Link route. Bootstrap
has not completed, and every protocol route answers the same opaque 503 until it
does.Do not guess — GET /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,nameequal to your credential’s display name, and authorize/par/token/profiles URLs on your application origin. Missing row →PLATFORM_BOOTSTRAP_DIRECTORY_FAILED. Publish it withPATCH /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?
Confirm the census is up
Confirm your identity is on the Trust List
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.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.