code + class), why (message), and — for
standing checks — which exact step (failed_step), so a console can show its
user the reason without opening a support ticket.
Runnable. Every response on this page was captured live from the partner
sandbox census at
https://phosra-api-sandbox-production.up.railway.app. Paste
any curl below and you will get the same shape back. The sandbox needs no API
key for the read and registration routes shown here.Every error at a glance
Each row is one(status, class) the census API can return, its one-line cause,
and the section that fixes it. This table is the whole census surface — nothing
returns off it.
The error envelope
Phosra returns one of two JSON envelopes. Both always carryerror, message,
and code. OCSS Trust Framework routes add two more fields.
string
Human-readable HTTP reason phrase (
"Bad Request", "Unauthorized", …). Mirrors
code. Do not branch on this string — branch on code and class.string
A bounded, hand-authored explanation. Safe to log and to surface to an operator.
It never contains a stack trace, a database string, or a caller identity.
integer
The numeric HTTP status, duplicated in the body so a client that only has the
parsed JSON still has the status.
string
OCSS routes only. The machine-readable failure class — one of the seven
values in OCSS error classes. Branch on this, not on the
prose
message. Absent on house routes (self-register, org/key management,
simple validation).string
standing_failure only. Names the exact rule-write check that failed —
authority_binding, scope, unregistered_slug, or band_exceeds_tier. Absent
(never null) on every other class.Branch on
code + class, never on message. The message text is tuned
for humans and may change between releases. code, class, and failed_step
are the stable contract.HTTP status codes
Success
Client errors
Server errors
OCSS error classes
OCSS Trust Framework routes (rule writes, envelopes, consent, receipts) return aclass from a closed set of seven. This is the complete vocabulary — there
are no others on the wire.
internal is an eighth class that exists only on the audit Receipt rail for
unexpected 500s. It is never serialized on the wire — a 500 always returns the
plain house body with no class. If you see class: "internal", it came from a
Receipt export, not an HTTP response.403 standing and scope
A403 means the request was cryptographically valid but not authorized. For
standing_failure, failed_step names the exact §6.2 check that rejected it:
Phosra Link route errors
Everything above this line comes from a census. Everything below comes from your own Link route — the handler that@phosra/link mounts under
/api/phosra/link/* — and is raised inside your process, before any connection
claim is made. Different envelope, different vocabulary, different debugging.
The Link envelope
Every Link route code
Eleven codes, and nothing else reaches the wire. Two of them map from more than one status, so branch on the pair —LINK_OPERATION_FAILED at 409 is a state
conflict; the same code at 503 is a transient adapter outage.
LINK_OPERATION_FAILED is by far the most common thing a new integration sees, and
it is the least informative. Roughly eighty of the handler’s internal rejections
collapse into it. Treat it as “look at the diagnostic”, not as “retry”.The localhost cookie trap
A local-development-only cause of401 UNAUTHENTICATED that looks like a Link bug
and is not one.
Cookies ignore the port. Every app you run on http://localhost shares one
cookie jar, whatever port it listens on. If your provider app and the platform app
both use the same session cookie name — and both do, out of the box, if both are
built on the same auth SDK — then signing in at the platform evicts the parent’s
session on your provider. The parent completes the platform’s OAuth leg, the
browser navigates back to GET …/link/callback, your authenticate(request)
adapter finds no session, and the route answers 401 UNAUTHENTICATED. Nothing about
the Link configuration is wrong.
Fix. Give each app a distinct cookie name in local development:
ORIGIN_REJECTED (Link SDK)
application.origin. The handler enforces it twice:
- The request URL’s own origin, on every method. Before any path or method
dispatch, the handler compares
new URL(request.url).originagainstapplication.origin(and rejects embedded credentials in the URL). This is derived from theHost/X-Forwarded-Hostheader, so a proxy or container that does not preserve the public host 403s everything — including the OAuth callback, which is a top-levelGETnavigation that carries noOriginheader at all. - The
Originheader, on mutations. Session creation and every lifecycle mutation additionally requirerequest.headers.get("origin")to equalapplication.originexactly — scheme, host, and port. This is what stops another site from driving Link ceremonies against your provider.
GET …/link/callback, it is gate (1) —
there is no Origin header on that navigation and no mutation involved, so the
header gate never ran. Fix the host your proxy forwards, not your CORS config.
What the parent sees. The Link dialog says the app isn’t set up for this
address and offers only Close — the fault is permanent configuration, so
retrying cannot fix it and the dialog does not pretend otherwise.
What you see. Your onDiagnostic hook receives:
application.origin in your credential and compare it with
the origin the app is actually served from:
- No
Originheader at all? See the warning above — that is the same 403. - Serving from
http://localhost? A production credential can never work there — production credentials are canonical-HTTPS-origin only. You need a sandbox credential, which may carryhttp://localhost[:port]orhttp://127.0.0.1[:port]: see local development. - Wrong port?
http://localhost:3005andhttp://localhost:3011are different origins. The credential must name the port you actually use. - Behind a proxy? Whatever terminates TLS must forward the public host. Gate
(1) reads
Host/X-Forwarded-Host, so a rewritten host 403s every route, callback included.
onDiagnostic: the only place the cause exists
onDiagnostic is the sole optional field on the Golden server config, and it is the
only channel that carries why anything failed. The wire body has a code and a
correlation id; the callback renders an HTML page with neither. Wire the hook before
you deploy.
string
Which lane failed:
start_session, complete_callback, lifecycle_status,
lifecycle_retry, lifecycle_disconnect, or platform_event_receive.string
The step within that lane. The complete vocabulary is below.
string
complete_callback and lifecycle_* only. Which of sixteen durable steps
refused — this is what turns LINK_OPERATION_FAILED into an actionable fault.boolean | "unknown"
Not always a boolean. The literal string
"unknown" is a valid value — do not
write if (d.retryable === false) and assume the complement means retryable.string
The only identifier that ties this failure to anything. It is the same value
the HTTP body returns as
error.correlation_id. Log it, and quote it to support.string
Whether the fault was the handler’s own rejection, a directory/trust problem, or
an SDK-internal error. Ten values, below.
Subcodes
causeClass — who is at fault
lifecycleSubstep — which durable step
pending_authority · child_authority · platform_authority ·
strict_callback_completion · return_navigation · finish · validate_input ·
load_link · verify_authority · transition_state · record_evidence ·
save_connection · ensure_rules · deliver_rules · load_authority ·
snapshot_projection
The callback failure page
A callback failure does not return JSON. Every unexpected failure onGET …/link/callback renders a self-contained HTML page instead, because the
browser is navigating to it:
We couldn’t finish connecting
Phosra couldn’t confirm every step of this connection.
Close this window and return to the app.
— <title> “Connection needs attention · Phosra Link”
That page carries no code and no correlation id — it is parent-facing copy, not
a debugging surface. The correlation id for that exact failure is generated
server-side and handed only to onDiagnostic. If you see this page and your
onDiagnostic hook is unwired, the failure is unrecoverable information.
Typed errors thrown in your own code
When you call the@phosra/link product API directly — createLink(...),
link.connect.*, link.enforce, mintEnforcementEndpoint, provisionProfiles,
ingestConsentAttestation — failures arrive as typed LinkError subclasses, not as
HTTP envelopes. Guard with isLinkError(e) and branch on e.code. These thirteen
codes plus the catch-all are the frozen contract; the prose .message is not.
Inside the Golden parent route these are invisible.
createGoldenLinkServer
catches everything and collapses it to LINK_OPERATION_FAILED on the wire; the
diagnostic’s causeClass (link_lifecycle_error, error, type_error) is your
only signal there. The typed classes are what you catch when you call the product
API yourself.Configuration throws (before any request)
Two composition faults throw plainErrors at construction time with no field name
in the message. They are listed here because they are otherwise unsearchable:
Platform bootstrap (@phosra/gatekeeper)
If you are the platform side, a different SDK fails differently. Every/api/phosra/* route answers a uniform 503 PHOSRA_NOT_READY until bootstrap
completes, and the reason is again only in onDiagnostic — as one of five stage
subcodes naming which half of boot refused:
Plus
PLATFORM_SCHEMA_MIGRATION_FAILED and PLATFORM_SCHEMA_READINESS_FAILED for
the database half. Do not guess between them: GET /api/phosra/status names the
outstanding precondition directly. Every code and its fix is on
Readiness.
Worked examples
Each block is a real request against the sandbox and the exact bytes it returns.401 · missing signature
A signed route (here, an enforcement confirmation) rejects a request with no RFC 9421 headers.curl
Response · 401
404 · unknown provider
curl
Response · 404
Full request/response for this route:
GET /providers/{did}/connect.
A registered-but-unconfigured provider (e.g. did:ocss:courier) returns a
different 404 body — "provider connect config not available" — so you can
tell “unknown DID” from “known DID, no OAuth seeded” apart.400 · invalid JSON
curl
Response · 400
400 · missing required field
curl
Response · 400
400 · closed vocabulary (unregistered slug)
A rule write with arule_category that is not in the OCSS registry. The
vocabulary is closed — the API rejects rather than coercing.
Response · 400
409 · duplicate registration
Self-registering a DID that is already on the Trust List. Registration is create-only — it never overwrites.curl
Response · 409
200 · successful self-register (for contrast)
Response · 200
key_id in the body is the exact did#kid the census published — sign
your subsequent requests with that identifier, character-for-character.
Handle errors in code
The same three-field contract —code, class, failed_step — is what your
error handler should branch on, in any language. Read the numeric status to
decide retry vs. fail, then read class (and failed_step on a
standing_failure) to decide what to tell the user. The prose message is for
logs, never for control flow.
The retry rule is the same everywhere: retry 429 and 5xx, re-sign once on
401, and treat every other 4xx as terminal (a 400 / 403 / 404 / 409 /
422 is deterministic — the same request returns the same error). The full matrix
is in Retry guidance below.
Each tab is a complete handler. The three envelopes above (House / OCSS /
standing-failure) all parse through it — a House 409 simply has no class, and
failed_step is present only when class is standing_failure.
Where
class and failed_step live per client. The generated Python and Go
clients surface the raw envelope bytes (ApiException.body,
GenericOpenAPIError.Body()) — parse them to reach class / failed_step. The
typed @phosra/sdk puts the whole parsed body on err.details, so
err.details.class and err.details.failed_step are already there. All three read
the same wire fields the worked examples above return. The
generator commands that produce these clients are in
Generate a typed client.Conditional requests (304)
Read endpoints that serve versioned artifacts (the Trust List, profiles, editions) return a strongETag. Send it back as If-None-Match and, when unchanged, you
get an empty 304 Not Modified instead of the full body.
curl
Staleness is not an error. A stale read serves last-known-good and reports it
through the staleness Receipt (§8.1 cl.4) — it never returns a 4xx. Use ETags to
poll cheaply; the
Cache-Control: public, max-age=300 header tells you how long
the artifact is fresh.Idempotency and replay
Write endpoints are idempotent on anIdempotency-Key. The two outcomes:
- Faithful replay — same key, byte-identical payload →
200 OKwith the original receipt bytes and headerOCSS-Replay: original. This is a success, not an error. - Conflicting replay — same key, different payload →
409 Conflict,class: "replay". The key is bound to its first payload; you cannot rebind it.
Rate limiting
Every/api/v1/* response carries live rate-limit headers. Read them — do not
assume a fixed quota. (The unauthenticated .well-known/ discovery reads — the
Trust List, profiles, editions — are unmetered and carry no rate-limit
headers; only the API surface is counted.)
curl
429, sleep until X-RateLimit-Reset and retry:
Retry guidance
Async jobs (
202 responses) are polled, not retried. Poll the job/receipt
endpoint named in the response rather than re-POSTing the trigger.Next steps
Troubleshooting
Symptom-first fixes for the errors above — auth, registration, consent,
connect, and disconnect races.
Authentication
How API keys, WorkOS sessions, and RFC 9421 request signing fit together.