Skip to main content
Every Phosra error is machine-readable and self-explaining. A rejected request tells you what failed (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.
Your Link route is a second, separate surface. The handler that @phosra/link mounts under /api/phosra/link/* runs in your process, uses a different envelope ({"error":{"code","correlation_id"}} — no message, no class), and has its own closed vocabulary of eleven codes, including ORIGIN_REJECTED. None of them appear in the table above. See Phosra Link route errors.

The error envelope

Phosra returns one of two JSON envelopes. Both always carry error, 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 a class 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

A 403 means the request was cryptographically valid but not authorized. For standing_failure, failed_step names the exact §6.2 check that rejected it:
Step (a) (the wire signature) is not a standing failure — it returns 401 signature_invalid and never carries a failed_step. failed_step appears only alongside class: "standing_failure".

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.
There is no message and no class. The Link envelope carries a code and a correlation id and nothing else — no prose, no field name, no origin, no identifier, no stack. That is deliberate: the route is browser-reachable, so it never narrates its own internals.The consequence is the single most important fact on this page: a Link failure’s root cause is not in the response. It exists in exactly one place — the onDiagnostic hook. If you have not wired that hook, a Link failure tells you nothing at all.
Eleven codes, and nothing else reaches the wire. Two of them map from more than one status, so branch on the pairLINK_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”.
A local-development-only cause of 401 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:
This costs nothing in production, where the two apps sit on different hosts and the jars are already separate.
Cause — there are two gates, and they return the same code. A v3 credential pins application.origin. The handler enforces it twice:
  1. The request URL’s own origin, on every method. Before any path or method dispatch, the handler compares new URL(request.url).origin against application.origin (and rejects embedded credentials in the URL). This is derived from the Host / X-Forwarded-Host header, so a proxy or container that does not preserve the public host 403s everything — including the OAuth callback, which is a top-level GET navigation that carries no Origin header at all.
  2. The Origin header, on mutations. Session creation and every lifecycle mutation additionally require request.headers.get("origin") to equal application.origin exactly — scheme, host, and port. This is what stops another site from driving Link ceremonies against your provider.
A missing Origin header is rejected exactly like a mismatched one. The check is a strict inequality, and null !== "https://app.example". curl, Postman, and server-side smoke tests send no Origin by default, so they all get 403 ORIGIN_REJECTED even when your credential is perfectly correct — and the obvious next move (decode the credential, compare the origins) then confirms the two do match, leaving you stuck.When testing a mutation by hand, add the header explicitly:
GET …/link/sessions/{id} (status) is the one operation exempt from the header gate, which makes it a usable liveness probe. It is still subject to gate (1).
Which gate am I on? If the 403 is on 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.
That honest copy is conditional on your code. The dialog shows it only when the rejection thrown by your createSession adapter 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") gets the generic branch instead — “We couldn’t start Phosra Link”, with a Try again button that can never succeed. Lift the code off the response body:
What you see. Your onDiagnostic hook receives:
Diagnostics are content-free by contract: the origins themselves are never in the payload, so compare them yourself. Fix. Decode the application.origin in your credential and compare it with the origin the app is actually served from:
  • No Origin header 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 carry http://localhost[:port] or http://127.0.0.1[:port]: see local development.
  • Wrong port? http://localhost:3005 and http://localhost:3011 are 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
save_connection and record_evidence are the two you will actually hit first. Both mean your database refused a write — most often because the package schema migration has not been re-run after an SDK upgrade. Run link.migrate() (or apply printLinkSchema()) and retry. The failure mode otherwise is a deep runtime lifecycle error with no clear message.

The callback failure page

A callback failure does not return JSON. Every unexpected failure on GET …/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 plain Errors 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.
Two bootstrap preconditions have no equivalent on the provider side and surprise everyone who meets them: the census’s operating router must itself resolve active and accredited, or no platform can boot on that census; and your own directory row must already exist before first boot — PLATFORM_BOOTSTRAP_DIRECTORY_FAILED on a brand-new platform almost always means you never published one. PATCH /api/v1/platforms/{did}/connect is the self-declaration route that publishes it.

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 a rule_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
The 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.
Retry-After is not sent on Phosra 429s. PhosraRateLimitError.retryAfter reads the Retry-After header, which the sandbox does not emit — it returns X-RateLimit-Reset (a Unix timestamp) instead. So retryAfter is often undefined; compute the wait from X-RateLimit-Reset as shown under Rate limiting.

Conditional requests (304)

Read endpoints that serve versioned artifacts (the Trust List, profiles, editions) return a strong ETag. 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 an Idempotency-Key. The two outcomes:
  • Faithful replay — same key, byte-identical payload → 200 OK with the original receipt bytes and header OCSS-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.
A 409 replay almost always means a retried request mutated its body between attempts (a regenerated timestamp, nonce, or list order). Freeze the payload before you attach the key, and reuse both together on retry.

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
On a 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.