Skip to main content
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.

Base URL

export SANDBOX="https://phosra-api-sandbox-production.up.railway.app"
No key, no signup. The sandbox is a full copy of the API seeded with the demo family (Mia, Leo, Ava) and the accredited 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.
InputEndpointOutcomeclass
did:ocss:looplineGET /providers/{did}/connect200 — configured provider, happy path
did:ocss:courierGET /providers/{did}/connect404 — accredited but unconfigurednot_found
any unknown DID, e.g. did:ocss:nopeGET /providers/{did}/connect404 — provider not foundnot_found
?decision=approveGET /oauth/authorize302?code=sbxauth_… (allow)
?decision=denyGET /oauth/authorize302?error=access_denied (force deny)
a DID already on the list, e.g. did:ocss:looplinePOST /advisors/self-register409did_already_registered(house)
body {} (no did)POST /advisors/self-register400did is required(house)
public_key_b64url not 32 bytesPOST /advisors/self-register400invalid_public_key_b64url(house)
body without child_namePOST /setup/quick400child_name is required(house)
birth_date not YYYY-MM-DDPOST /setup/quick500 — see the honesty note(house)
any signed endpoint, no Signature headerPOST /enforcement-endpoints, /harm-context, …401 — missing signaturesignature_invalid
> 100 mutating calls / window, one callerany POST /api/v1/*429Retry-After: 60(house)
valid signature, tier too low for the bandPOST /policies/{id}/rules (signed)403band_exceeds_tierstanding_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.
curl -s "$SANDBOX/api/v1/providers/did:ocss:loopline/connect"
Verbatim responses:
200 — did:ocss:loopline
{
  "authorize_url": "https://phosra-api-sandbox-production.up.railway.app/oauth/authorize",
  "token_url": "https://phosra-api-sandbox-production.up.railway.app/oauth/token",
  "profiles_url": "https://phosra-api-sandbox-production.up.railway.app/oauth/profiles",
  "scopes": ["child_profiles.read"],
  "name": "Loopline"
}
404 — did:ocss:courier (accredited, no connect config)
{ "error": "Not Found", "message": "provider connect config not available", "code": 404, "class": "not_found" }
404 — unknown DID
{ "error": "Not Found", "message": "provider not found", "code": 404, "class": "not_found" }
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.
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.
Phosra sandbox OAuth consent page listing the seeded children Mia, Leo, and Ava with Approve and Deny buttons
curl -s -D - -o /dev/null \
  "$SANDBOX/oauth/authorize?redirect_uri=https%3A%2F%2Fexample.com%2Fcb&state=xyz&decision=approve" \
  | grep -i '^location'
# location: https://example.com/cb?code=sbxauth_wxG3vLkfEk4OU_gWk0YsDvUaaTyWKAbs&state=xyz
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.
# 409 — did:ocss:loopline is already on the sandbox Trust List
curl -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"}'
409
{ "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:
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/quick400 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.
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}
Known gap, stated honestly: a birth_date that is not YYYY-MM-DD currently returns 500 Internal Server Error, not a 400. Treat YYYY-MM-DD as a hard client-side requirement until this is tightened. This is the one row on this page that is a bug rather than a designed outcome — flagged so you do not build a retry loop around a 500 that will never succeed.

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.
curl -s -X POST "$SANDBOX/api/v1/enforcement-endpoints" \
  -H "Content-Type: application/json" -d '{}'
401
{ "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 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:
x-ratelimit-limit: 100
x-ratelimit-remaining: 52
x-ratelimit-reset: 1783342740
Exhaust the window (> 100 mutating calls from one caller) and the next call is a 429:
HTTP/2 429
retry-after: 60
x-ratelimit-limit: 100
x-ratelimit-remaining: 0
x-ratelimit-reset: 1783342740

Too 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.
  • Outcome: 403, class: standing_failure, failed_step: band_exceeds_tier.
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 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:
LeverHowEffect
X-Sandbox-Session: <any-string>header on any callKeys your data to a stable per-session caller; a fresh value starts from a clean slate
family_id on POST /setup/quickthread the id from a prior responseIdempotent retry — dedups on (family_id, child_name, birth_date), returns the same child
Details and worked examples: Sandbox test data › Reset and idempotency.

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.