Limits & quotas
This page covers the resource ceilings — how big a page you can pull, how large a
request body may be, how long an identifier can be. These are separate from
rate limiting: a rate limit throttles how often you may call
(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-production.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 on limit, 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) | ✅ |
The three list endpoints do not clamp the same way. enforce/history accepts a limit
only in 1–100; ask for 101 (or 0, or a non-number) and it silently uses the 20
default, so a too-big limit returns fewer rows, not the ceiling — always send a value
≤ 100. webhooks/{id}/deliveries and viewing-history accept any positive limit
with no server bound; a page size above 100 is advisory only — it works today but
isn't a guaranteed ceiling, and large pages compound your rate-limit cost
per call. Prefer 100 and page.
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.
Every value below is the exact 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:
BASE=https://phosra-api-sandbox-production.up.railway.app
# Build a ~40 KiB JSON body — over the 32 KiB accreditation-apply cap
BIG=$(printf 'a%.0s' {1..40000})
printf '{"provider_did":"did:ocss:x","evidence":"%s"}' "$BIG" \
| curl -s -X POST "$BASE/api/v1/accreditation/apply" \
-H 'Content-Type: application/json' \
--data-binary @- -w '\nHTTP %{http_code}\n'{
"error": "Request Entity Too Large",
"message": "request body exceeds the accreditation-apply size bound",
"code": 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)) |
The 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:
BASE=https://phosra-api-sandbox-production.up.railway.app
LONG=$(printf 'a%.0s' {1..200})
curl -s -X POST "$BASE/api/v1/advisors/register" \
-H 'Content-Type: application/json' \
-d "{\"did\":\"did:ocss:$LONG\",\"public_key_b64url\":\"x\"}"{
"error": "Bad Request",
"message": "invalid_did: ocss: did:ocss slug is 200 bytes, exceeding the 128-byte bound",
"code": 400
}The slug and key-id bounds are counted in bytes, not characters. A multi-byte UTF-8 slug reaches the 128-byte ceiling in fewer than 128 visible characters. Keep DIDs ASCII and short.
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.