429 Too Many Requests), while a resource limit bounds how much a single call may
carry or return (a 400, a 413, or a silently clamped page).
Sourced, not guessed. Every enforced value below is taken from a schema
constraint or the census validation source. Two of them — the
did:ocss slug bound
(400) and a request-body cap (413) — were reproduced live against the partner
sandbox at https://phosra-api-sandbox-staging.up.railway.app on 2026-07-06;
both real responses are pasted below verbatim. Ceilings the API does not currently
enforce are labelled Advisory — do not design against them as hard guarantees.Rate limit vs. resource limit
| Rate limit | Resource limit | |
|---|---|---|
| Bounds | requests per 60 s window | the size of one request/response |
| Signal | 429 + Retry-After + X-RateLimit-* | 400 (too long), 413 (body too large), or a clamped page |
| Fix | back off and retry — see Rate limits | shrink the payload / page and resend |
| Retryable? | yes, after the window resets | no — the same oversized input fails again |
A
413/400 from a resource limit is not retryable as-is. Retrying the identical
oversized body just fails again. Split the work (smaller page, fewer items, shorter
field) before you resend.Pagination page sizes
List endpoints never error onlimit, but they don’t all behave the same way — some
enforce a hard ceiling, some accept anything. Read the column below before you assume a big
page works. See Pagination for how to walk every page.
| Endpoint | limit default | limit bound (enforced) | offset |
|---|---|---|---|
GET /api/v1/enforce/history | 20 | 100 — values outside 1–100 fall back to the 20 default, not to 100 | ✅ |
GET /api/v1/webhooks/{id}/deliveries | 50 | (unbounded — advisory: keep ≤ 100) | — |
GET /api/v1/viewing-history/{childId} | 50 | (unbounded — advisory: keep ≤ 100) | ✅ |
These page-size values are read from the request-handler and repository source
(
browser_enforcement, webhook, and viewing_history), not a live probe — the three
list endpoints require an authenticated session, so they can’t be reproduced with an
anonymous curl.Request-body size caps
There is no single global body cap — the census enforces size in two layers, and a request must clear both:- The outer signed-envelope cap —
10 MiB. Every RFC-9421-signed census write is buffered whole by the signature verifier (Verifier.Require) so the content digest can be checked over the exact bytes the handler will read. That buffer is bounded at 10 MiB — the effective default cap on every signed endpoint, including the two every partner calls:POST /api/v1/policies/{policyId}/rules(rule-write) andPOST /api/v1/webhooks/inbound/{source}(ingest). Exceed it and verification fails before the handler runs, with amalformed-class OCSS error (not a413). - The inner per-handler cap. Each handler then re-bounds its own body to a tighter, payload-sized
limit. How the limit is enforced determines the failure signal:
http.MaxBytesReaderendpoints fail hard at the boundary — a clean413(or, where the read is wrapped in a JSON decoder, a400).io.LimitReaderendpoints silently truncate at the limit, so the validator rejects the now-broken JSON with a400/malformed— you never see a413. Don’t design against a413from these; watch the cap yourself.
http.MaxBytesReader / io.LimitReader constant in the census
source, paired with the exact method + path it guards.
Outer envelope — all signed census writes
| Method + path | Cap | Reader | On exceed |
|---|---|---|---|
POST /api/v1/policies/{policyId}/rules (rule-write — the endpoint every partner calls) | 10 MiB | verifier buffer | malformed-class error (…exceeds the 10 MiB census bound) |
POST /api/v1/webhooks/inbound/{source} (ingest) | 10 MiB | verifier buffer | malformed-class error |
| Any other RFC-9421-signed census write (alerts, harm-context, DSR, proposals, enforcement-confirmations, …) with no tighter inner cap listed below | 10 MiB (default) | verifier buffer | malformed-class error |
The 10 MiB envelope is the ceiling, not a target — the largest real census payloads (bulk CSM
reviews) sit far below it. It is enforced in
internal/ocsshttp/verifier.go (maxBodyBytes = 10 << 20)
and applies uniformly to everything behind Verifier.Require.Inner per-handler caps
| Method + path | Inner cap | Reader | On exceed |
|---|---|---|---|
POST /api/v1/subjects (signed; enforcement-agent only) | 1 MiB | MaxBytesReader in JSON decoder | 400 invalid_json |
POST /api/v1/compliance-bundles (signed; fan-out — N receipts) | 512 KB | io.LimitReader | truncates → decode fails (400/malformed) |
POST /api/v1/routing-manifests (signed; fan-out) | 16 KiB | io.LimitReader | truncates → decode fails |
POST /api/v1/minimization-attestation (signed) | 16 KiB | io.LimitReader | truncates → strict decode fails (malformed) |
POST /api/v1/abuse-signal (signed) | 8 KiB | io.LimitReader | truncates → strict decode fails (malformed) |
POST /api/v1/receipts/{id}/appeal (redress; alias …/alerts/{id}/appeal) | 64 KiB | MaxBytesReader | malformed-class error |
POST /api/v1/advisors/{id}/payload-key (advisor payload-key declaration) | 16 KiB | MaxBytesReader | 413 (…declaration size bound) |
POST /api/v1/accreditation/apply | 32 KiB | MaxBytesReader | 413 (…accreditation-apply size bound) |
POST /api/v1/enclaves | 64 KiB | MaxBytesReader | 413 (…enclave registration size bound) |
POST /api/v1/webhooks/stripe | 64 KiB (65536 bytes) | MaxBytesReader | 413 |
POST /api/v1/advisors/register (deprecated self-register shim — not signed) | 1 MiB | io.LimitReader | truncates → 400 (no 413) |
POST /api/v1/admin/advisors/{id}/record-attestation (admin-only) | 16 KiB | MaxBytesReader | 400 read_failed |
POST /api/v1/sandbox/record-attestation (sandbox-only; F2 conformance loop) | 32 KiB | MaxBytesReader | 413 |
POST /classify (reference-enclave service — a separate host, not the /api/v1 census) | 1 MiB | MaxBytesReader | 413 |
All other
/api/v1/... write endpoints fall back to the 10 MiB outer envelope above — they
set no tighter inner cap. These caps are generous for well-formed JSON: a rule bundle or attestation
is kilobytes, not megabytes. If you are approaching a cap you are almost certainly batching too much
into one call; split it and lean on idempotency keys so a retry is safe.A real 413, end to end
Post more than a MaxBytesReader cap and you get a 413 with a machine-readable body. This is
captured live from the partner sandbox — a 40 KiB body against the 32 KiB accreditation/apply
cap:
curl
Response · 413
The
message names the surface that rejected you (…accreditation-apply size bound), so
a 413 tells you exactly which cap you hit. The cap fires before any field validation,
so an oversized body never partially applies. Each endpoint’s message is worded for its own
cap (…enclave registration size bound, etc.).Identifier & field lengths
| Field | Max length | Enforced by |
|---|---|---|
did:ocss:<slug> — the slug | 128 bytes | server validation (live-probed) |
did:ocss:…#<kid> — the key-id fragment | 128 bytes | server validation |
Family / child / policy name | 255 chars | database column (VARCHAR(255)) |
User email / name | 255 chars | database column (VARCHAR(255)) |
name/email bounds are the database column width (VARCHAR(255)) — an over-length
value is rejected at write time by Postgres, so it surfaces as a write failure rather than a
pre-validated 400. The did:ocss slug bound, by contrast, is live-enforced in the
handler — a 200-byte slug is rejected before any lookup. Reproduce it:
curl
Response · 400
Per-account counts (advisory)
Phosra does not currently cap the number of children per family, webhooks per family, or active API keys per org. These are advisory — plan for reasonable numbers, and do not rely on the absence of a cap as a contract; a future release may introduce one.| Resource | Enforced ceiling | Guidance |
|---|---|---|
| Children per family | none (advisory) | Real households are small; hundreds is a data-model smell, not a use case. |
| Webhooks per family | none (advisory) | One or two endpoints per family is typical. Fan out downstream, not at the webhook layer. |
| Active API keys per org | none (advisory) | Adding keys does not add quota — every key in an org shares one rate-limit bucket. Rotate, don’t accumulate. |
Because every key in an org shares one rate-limit window, minting more keys buys you
no extra throughput — it only spreads the same 100 req/min across more callers. Need more
headroom? Raise
rate_limit_rpm on your plan, not your key count.Next steps
Rate limits
The per-window request quota, the
X-RateLimit-* headers, and a copy-paste backoff loop.Pagination
Walk every page correctly with the
limit/offset and cursor rules.Errors
The full status/class table — where
400, 413, and 429 each sit.Idempotency
Make a retry after a clamp or split safe to replay.