Stripe gives you 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.
No key, no signup. The sandbox is an evaluation environment seeded with the demo family
(Mia, Leo, Ava) and synthetic reference providers. Every 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. Each class 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 — known synthetic fixture 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
400 — invalid birth_date format, use YYYY-MM-DD
(house)
any signed endpoint, noSignature header
POST /enforcement-endpoints, /harm-context, …
401 — missing signature
signature_invalid
> 100 /api/v1/* calls / window, one caller
any /api/v1/* request (GETs count too)
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.
The two 404s carry different messages on the same class. did:ocss:courier
is a synthetic Trust List fixture but has no OAuth connect config — the
"known test fixture, 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: the decision 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.
The live sandbox consent page — GET /oauth/authorize. Approving shares the seeded Mia, Leo, and Ava profiles; Deny returns error=access_denied.
Both return 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.
bash
# 409 — did:ocss:loopline is already on the sandbox Trust Listcurl -s -X POST "$SANDBOX/api/v1/advisors/self-register" \ -H "Content-Type: application/json" \ -d '{"did":"did:ocss:loopline","public_key_b64url":"1NDtvUgkYV6h6XzR3y-FgAdKmqQ-i7XiWen7joyJYgA"}'
json
{ "error": "Conflict", "message": "did_already_registered: this DID is already on the trust list; self-register cannot overwrite existing entries", "code": 409 }
The two validation 400s:
bash
curl -s -X POST "$SANDBOX/api/v1/advisors/self-register" \ -H "Content-Type: application/json" -d '{}'# {"error":"Bad Request","message":"did is required","code":400}
Self-register is not idempotent — it is a create, not an upsert. A second call
with the same DID is a 409, never a silent success. To register a fresh DID that
returns 200 provisional, use a DID you have never registered (e.g.
did:ocss:my-app-$(date +%s)) — see Test in the sandbox.
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 — two clean 400s
setup/quick cleanly validates both a missing name and a malformed date — both are
caller errors, and both return 400, never a 500.
bash
curl -s -X POST "$SANDBOX/api/v1/setup/quick" \ -H "Content-Type: application/json" \ -d '{"birth_date":"2016-03-15"}'# {"error":"Bad Request","message":"child_name is required","code":400}
Treat YYYY-MM-DD as a hard client-side requirement regardless — this 400 still fails
the call, it just fails it cleanly and tells you exactly what to fix. (Older deployments
that predate this validation may still return 500 Internal Server Error for a malformed
date; if you see that instead of the 400 above, you're hitting a stale build.)
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.
{ "error": "Unauthorized", "message": "exactly one Signature-Input and one Signature header are required", "code": 401, "class": "signature_invalid" }
429 — the rate limit is real
Every metered response — reads included — carries live rate-limit headers, and the
limit does trip. The product surface under /api/v1/* shares one window; a
GET /api/v1/platforms costs the same as a POST. Unmetered by design: /health, the
/.well-known/ocss/* discovery reads (Trust List, succession record), the Annex B
editions read (GET /api/v1/annex-b/editions/{n}), the sandbox /oauth/* consent
surface, and the signed census safety paths (enforcement endpoints, confirmations,
harm-context — deliberately never rate-gated, the fail-open invariant) — so hammer
those freely.
Exhaust the window (> 100 /api/v1/* calls from one caller) and the next call is a 429:
http
HTTP/2 429retry-after: 60x-ratelimit-limit: 100x-ratelimit-remaining: 0x-ratelimit-reset: 1783342740Too Many Requests
The correct handler reads 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 provisional DID, writing a
Restricted-band rule to POST /policies/{id}/rules.
A forged or mismatched 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 another fixture value is not a production governance decision. See
the design-partner and production roadmap.
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