> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox test values

> Phosra's answer to Stripe test cards — the copy-paste inputs that deterministically drive the sandbox to a known outcome: allow vs deny, 400, 404, 409, 429, and the tier-gated 403. Every trigger verified live.

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.

<Info>
  Two neighbouring pages cover the *values*; this one covers the *outcomes*.
  [Sandbox test data](/api-reference/sandbox-test-data) is the flat lookup of every
  **fixed** value (child IDs, provider DIDs, the 22 platforms). [Test in the
  sandbox](/guides/test-in-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`.
</Info>

## Base URL

```bash theme={null}
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.

<Note>
  **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.
</Note>

## The trigger table

Scan this, copy the row you need. Each `class` matches the [Errors reference](/errors);
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 `404`s

`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.

<CodeGroup>
  ```bash Happy path (200) theme={null}
  curl -s "$SANDBOX/api/v1/providers/did:ocss:loopline/connect"
  ```

  ```bash Unconfigured (404) theme={null}
  curl -s "$SANDBOX/api/v1/providers/did:ocss:courier/connect"
  ```

  ```bash Unknown DID (404) theme={null}
  curl -s "$SANDBOX/api/v1/providers/did:ocss:nope/connect"
  ```
</CodeGroup>

**Verbatim responses:**

```json 200 — did:ocss:loopline theme={null}
{
  "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"
}
```

```json 404 — did:ocss:courier (accredited, no connect config) theme={null}
{ "error": "Not Found", "message": "provider connect config not available", "code": 404, "class": "not_found" }
```

```json 404 — unknown DID theme={null}
{ "error": "Not Found", "message": "provider not found", "code": 404, "class": "not_found" }
```

<Note>
  The two `404`s carry **different `message`s 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.
</Note>

## 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.

<Frame caption="The live sandbox consent page — GET /oauth/authorize. Approving shares the seeded Mia, Leo, and Ava profiles; Deny returns error=access_denied.">
  <img src="https://mintcdn.com/phosra/o0ansqUg4hqAQihW/images/sandbox-consent-approve-deny.jpg?fit=max&auto=format&n=o0ansqUg4hqAQihW&q=85&s=10900b1b81b27f5a26a9f8ed628df303" alt="Phosra sandbox OAuth consent page listing the seeded children Mia, Leo, and Ava with Approve and Deny buttons" width="560" height="424" data-path="images/sandbox-consent-approve-deny.jpg" />
</Frame>

<CodeGroup>
  ```bash Approve → authorization code theme={null}
  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
  ```

  ```bash Deny → access_denied theme={null}
  curl -s -D - -o /dev/null \
    "$SANDBOX/oauth/authorize?redirect_uri=https%3A%2F%2Fexample.com%2Fcb&state=xyz&decision=deny" \
    | grep -i '^location'
  # location: https://example.com/cb?error=access_denied&state=xyz
  ```
</CodeGroup>

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](/guides/mobile-integration) drives.

<Note>
  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.
</Note>

## Self-register — `409` and the two `400`s

`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 theme={null}
# 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"}'
```

```json 409 theme={null}
{ "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 `400`s:

<CodeGroup>
  ```bash Missing did theme={null}
  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}
  ```

  ```bash Malformed public key theme={null}
  curl -s -X POST "$SANDBOX/api/v1/advisors/self-register" \
    -H "Content-Type: application/json" \
    -d '{"did":"did:ocss:test","public_key_b64url":"tooshort"}'
  # {"error":"Bad Request","message":"invalid_public_key_b64url: must decode to 32 bytes, got 6","code":400}
  ```
</CodeGroup>

<Warning>
  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](/guides/test-in-sandbox#self-register-a-provisional-did).
</Warning>

<Note>
  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.
</Note>

## `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`.

<CodeGroup>
  ```bash Missing child_name → 400 theme={null}
  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}
  ```

  ```bash Malformed birth_date → 500 (see below) theme={null}
  curl -s -X POST "$SANDBOX/api/v1/setup/quick" \
    -H "Content-Type: application/json" \
    -d '{"child_name":"Test","birth_date":"not-a-date"}'
  # {"error":"Internal Server Error","message":"internal server error","code":500}
  ```
</CodeGroup>

<Warning>
  **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.
</Warning>

## `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](/authentication) signature. Calling any of them with no
`Signature` header is a deterministic `401` — useful for asserting your client attaches
one before it ships.

```bash theme={null}
curl -s -X POST "$SANDBOX/api/v1/enforcement-endpoints" \
  -H "Content-Type: application/json" -d '{}'
```

```json 401 theme={null}
{ "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:

```http theme={null}
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 theme={null}
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](/guides/recipes/backoff-retry-429). A `429` is the only
error on this page worth retrying automatically; the `4xx`s are deterministic and a
blind retry returns the same result.

<Note>
  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.
</Note>

## `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](/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](/integration/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 |

Details and worked examples: [Sandbox test data › Reset and idempotency](/api-reference/sandbox-test-data#reset-and-idempotency).

## Now build something

<CardGroup cols={3}>
  <Card title="Errors reference" icon="triangle-exclamation" href="/errors">
    Every status, class, and `failed_step` these triggers map to.
  </Card>

  <Card title="Sandbox test data" icon="table-cells" href="/api-reference/sandbox-test-data">
    The fixed values — child IDs, provider DIDs, the 22 platforms.
  </Card>

  <Card title="Back off a 429" icon="rotate" href="/guides/recipes/backoff-retry-429">
    The retry loop for the one error worth retrying.
  </Card>
</CardGroup>
