4000 0000 0000 0002 to force a decline. This page is the same
idea for Phosra: a flat table of concrete inputs that make the sandbox return a
specific outcome — the happy path, a forced deny, and every error status a real
integration has to handle (400, 404, 409, 429, and the tier-gated 403).
Every row was executed live against the sandbox while writing this page, and the
response bytes below are pasted verbatim. Nothing here is hypothetical.
Two neighbouring pages cover the values; this one covers the outcomes.
Sandbox test data is the flat lookup of every
fixed value (child IDs, provider DIDs, the 22 platforms). Test in the
sandbox is the guided tour. This page is the
error-and-branch trigger table — reach for it when you are writing the code that
handles a
404 or backs off a 429.Base URL
curl
below runs against $SANDBOX exactly as written.
There is no separate “magic value” namespace. Unlike a payments API, Phosra’s
triggers are real endpoint behaviours, not sentinel strings — a
404 comes from a
DID that genuinely is not configured, a 409 from a DID that genuinely is already on
the Trust List. That means every trigger here also matches production semantics: the
same input shape produces the same class of outcome against a live key. The only
sandbox-specific affordances are the seeded fixtures (the demo family, did:ocss:loopline)
and the self-serve reset levers, both documented below.The trigger table
Scan this, copy the row you need. Eachclass matches the Errors reference;
each links to the worked example below.
| Input | Endpoint | Outcome | class |
|---|---|---|---|
did:ocss:loopline | GET /providers/{did}/connect | 200 — configured provider, happy path | — |
did:ocss:courier | GET /providers/{did}/connect | 404 — accredited but unconfigured | not_found |
any unknown DID, e.g. did:ocss:nope | GET /providers/{did}/connect | 404 — provider not found | not_found |
?decision=approve | GET /oauth/authorize | 302 → ?code=sbxauth_… (allow) | — |
?decision=deny | GET /oauth/authorize | 302 → ?error=access_denied (force deny) | — |
a DID already on the list, e.g. did:ocss:loopline | POST /advisors/self-register | 409 — did_already_registered | (house) |
body {} (no did) | POST /advisors/self-register | 400 — did is required | (house) |
public_key_b64url not 32 bytes | POST /advisors/self-register | 400 — invalid_public_key_b64url | (house) |
body without child_name | POST /setup/quick | 400 — child_name is required | (house) |
birth_date not YYYY-MM-DD | POST /setup/quick | 500 — see the honesty note | (house) |
any signed endpoint, no Signature header | POST /enforcement-endpoints, /harm-context, … | 401 — missing signature | signature_invalid |
| > 100 mutating calls / window, one caller | any POST /api/v1/* | 429 — Retry-After: 60 | (house) |
| valid signature, tier too low for the band | POST /policies/{id}/rules (signed) | 403 — band_exceeds_tier | standing_failure |
Provider connect — 200 vs the two 404s
GET /api/v1/providers/{did}/connect has three deterministic branches. This is the
single most useful pair to test against, because it lets you exercise both the
happy-path and not-found arms of your connect code without any auth.
200 — did:ocss:loopline
404 — did:ocss:courier (accredited, no connect config)
404 — unknown DID
The two
404s carry different messages on the same class. did:ocss:courier
is on the Trust List (accredited) but has no OAuth connect config — the
“real provider, not wired for connect” case. An unknown DID is a clean miss. Your
code should branch on the message, not just the status.Allow vs force-deny — the OAuth consent branch
The reference-provider consent screen is a real, server-rendered page. It is the sandbox’s allow/deny switch: thedecision query parameter deterministically
picks which redirect you get back, so you can drive both the granted and the
user-declined paths of your connect handler.

302. The state you send is echoed back on both branches, so you can
verify your CSRF check on the deny path too. Custom mobile redirect schemes (e.g.
propagate://cb) are allowed — this is the same surface the
Propagate iOS parent flow drives.
The sandbox consent flow is stateless: the
sbxauth_… code and the sbxtok_…
token it exchanges for are opaque single-value strings, not persisted and not
PKCE-bound. Any freshly minted sbxauth_ code exchanges cleanly. This is a sandbox
affordance to keep tests short — a production IdP binds and expires both.Self-register — 409 and the two 400s
POST /api/v1/advisors/self-register is the fastest way to a deterministic 409:
send a DID that is already on the Trust List. The seeded did:ocss:loopline is always
present, so this reproduces with zero setup.
409
400s:
On the
-staging host, a 200 self-register additionally carries a
"warning" field steering you to the partner sandbox
(…-production.up.railway.app). The partner (-production) host — the base URL on
this page — returns no warning. The warning is keyed off X-Forwarded-Host, so it is
itself a deterministic, host-based trigger.POST /setup/quick — 400 vs the honest 500
setup/quick cleanly validates a missing name but does not yet cleanly validate a
malformed date — we document that truthfully rather than pretend it returns a 400.
401 — every signed endpoint without a signature
The write endpoints (/enforcement-endpoints, /harm-context,
/policies/{id}/rules, /platforms/{did}/endpoints, the sandbox consent-attestation
mint) require an RFC 9421 signature. Calling any of them with no
Signature header is a deterministic 401 — useful for asserting your client attaches
one before it ships.
401
429 — the rate limit is real
Every POST /api/v1/* response carries live rate-limit headers, and the limit does
trip. Reads — /health, the Trust List, /oauth/profiles, editions — are unmetered,
so hammer discovery freely; it is the mutating calls that count.
A healthy response, mid-window:
429:
Retry-After (or X-RateLimit-Reset), sleeps, and retries
once — see the backoff recipe. A 429 is the only
error on this page worth retrying automatically; the 4xxs are deterministic and a
blind retry returns the same result.
The captured
x-ratelimit-reset (1783342740) is a Unix epoch from the moment of
capture; yours will differ. Assert on the header’s presence and the 429 status,
not the literal value.403 — the tier gate (signed requests only)
A 403 is the one outcome you cannot reach with a bare curl, and we call that
out rather than fake it: it means the request was cryptographically valid but not
authorized. The canonical trigger is a provisional-tier DID (what
self-register gives you) signing a rule write whose enforcement band its tier cannot
reach:
- Input: a valid RFC 9421 signature from a
provisionalDID, writing a Restricted-band rule toPOST /policies/{id}/rules. - Outcome:
403,class: standing_failure,failed_step: band_exceeds_tier.
consent:attestation standing produces the sibling
403 scope_failure. Both are fully specified — with the exact failed_step values —
in Errors › 403 standing and scope. To raise your
tier from provisional to verified/accredited (the tiers that clear the gate) is a
governance step, not an API call — see Production accreditation.
Reset & repeatability
The sandbox has no destructive reset button. Two levers keep your test runs clean:| Lever | How | Effect |
|---|---|---|
X-Sandbox-Session: <any-string> | header on any call | Keys your data to a stable per-session caller; a fresh value starts from a clean slate |
family_id on POST /setup/quick | thread the id from a prior response | Idempotent retry — dedups on (family_id, child_name, birth_date), returns the same child |
Now build something
Errors reference
Every status, class, and
failed_step these triggers map to.Sandbox test data
The fixed values — child IDs, provider DIDs, the 22 platforms.
Back off a 429
The retry loop for the one error worth retrying.