# Limits

Limits are per tier, enforced per caller, and published here and at `GET /v1/limits`. Every response carries
`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `Ckrets-Tier`. Errors are `application/problem+json`. A
create the API refuses (`400`, `413`, `428`, `429`) spends none of your creates. Every window is fixed, not rolling: the
clock hour and the day, both in UTC. **All times are UTC.** Every time in a body is RFC 3339 with `Z` and whole seconds
(`2026-10-24T01:11:09Z`), and every window resets on the UTC clock.
A `429` **with** `Retry-After` means slow down (`rate-limited`, `retryable: true`); a `429` **without** it means stop until
`resetsAt` or mint a key (`quota-exhausted`, `retryable: false`). A tier past its daily budget returns `503` with
`Retry-After`; signed and keyed tiers never see that `503`. A `503` with no `Retry-After` and no problem body comes from the platform in front of the API: usually it shed the load and nothing was done, but a write (a create, a reveal, a revoke) that ran past the platform's 15-second limit may have happened, so after a write check as for `outcome: "unknown"` before you send it again; after a read, wait 2 seconds and retry. Two `503 temporarily-unavailable` answers both carry `Retry-After`: tell them apart by the problem body, never by the
header. `retryable: true` with no `outcome` means nothing was done; `outcome: "unknown"` (with `retryable: false`) means a
write may have happened, so read the call in `check` before you send it again. Every TTL default is 7 days, on every
tier. Earlier values and every revision of this page are dated in [the changelog](/changelog.md).

| Limit | Anonymous (no key) | Signed agent (Web Bot Auth, planned) | Free key | Paid key (later) |
|---|---|---|---|---|
| Identity | IP (an IPv6 caller by its /64), plus a proof-of-work stamp on create when the tier is busy: more than 20 anonymous creates a second across the whole tier, whoever sends them (`Ckrets-Proof: stamp=…`; a stamp is always offered in `GET /v1/limits` and needed only after a `428`; it lives 120 s and is good for any number of creates from its network until then, each create still counted) | RFC 9421 HTTP message signature; the key is published in the operator's `/.well-known/http-message-signatures-directory`, so the operator's domain is accountable | the key: in v1 a lease of 24 h, extended by every call made with it, never past seven days from minting (long-lived keys minted with a passkey are planned); or a CI job's OIDC token, counted per repository | the key plus a billing account |
| Value size | 1 KB | 4 KB | 64 KB | 1 MB; files via presigned upload |
| TTL | 7 d default, 7 d max | 7 d default, 7 d max | 7 d default, 1 year (365 d) max | 7 d default, no max (`"never"`) |
| Reveals per secret | 1 | up to 3 | up to 10 | up to 1,000 |
| Creates (fixed windows: the clock hour, the UTC day) | at most 240 an hour, 1,200 a day per network (IPv4 /24, IPv6 /48), and within that 60 an hour, 300 a day per IP | 300 an hour, 3,000 a day per signing key | 1,000 a day | by plan |
| Key minting (`POST /v1/keys`; every key today is a lease; long-lived passkey keys are planned) | 10 keys an hour and 30 a day per address, 100 a day per network (IPv4 /24, IPv6 /48); one over is `429 quota-exhausted` with `limit` `mintsPerHour`, `mintsPerDay` or `mintsPerNetworkDay` and `resetsAt`, and a refused mint spends nothing. Each lease has its own 1,000 creates a day, so one address's keyed creates are bounded by its mints and the door's 300 creates a minute, not by a ceiling across its leases | n/a | n/a | n/a |
| Reveal attempts per token (a wrong passphrase counts; a wrong audience proof is recorded and alarmed but spends no attempt; the budget is set at create by the creator's tier, whoever reveals) | 5. Every wrong passphrase up to the 5th answers `401`; every call after the 5th is `410 attempts-exhausted` (the token is dead). A right passphrase reveals on any call while the budget lasts, the 5th included (after four wrong ones); inside a wait it gets the same `429` as a wrong one. After the nth counted attempt the token waits 2^(n-1) s before the next: 1, 2, 4, 8 s on a budget of 5 (so the 4th wrong passphrase, sent after the 4 s wait, is a `401` that starts an 8 s wait), up to 256 s on a budget of 10; a call inside the wait is a `429`, not counted. Each attempt is counted by one conditional write, so wrong passphrases sent at once get one `401` and `429`s for the rest. The wait and the count are per token, not per caller: guesses from many addresses share them, so anyone holding a bearer URL can use up its attempts | 5 | 10 | 10 |
| Concurrency | no per-caller in-flight cap in v1: the rates above and the door's per-address rates are the control | the same | the same | by plan |
| Audience (a ckrets key, a CI OIDC identity, an HTTP-signature key or a recipient public key must reveal) | optional | optional | optional; every agent example names one (a bearer link is shown only for a person who clicks) | optional |
| Bearer tokens (no audience) | yes, that is what the tier is for | yes | yes, discouraged | yes |
| Batches (`POST /v1/batches`): planned, every item with an audience. Today the path answers `501 not-in-v1` on every tier and at any size, so no batch limit applies yet | no | up to 20 items | up to 100 items | up to 5,000 |
| Requests to a person (`POST /v1/requests`) | no (the person must see who asks) | planned | 5 an hour, 20 a day, 3 open at once | 20 an hour, 100 a day, 5 open at once |
| Webhooks (signed, to a verified host only) | no | to the signing directory's host | to the key's verified domain | yes |
| Daily budget | a fixed daily budget for the whole tier's creates (only creates the per-address and per-network limits let through spend it: a refused create spends none), sized for normal use many times over (a test deployment has a smaller one, and may ask for the stamp on every anonymous create); past it, anonymous creates answer `503 tier-budget-exhausted` with `Retry-After` until 00:00 UTC | per signing key | none: 1,000 creates a day is the bound | by plan |
| Kill switch | per IP range and global for the tier | per signing key | per key: revoke, pause, lower. A lease whose window or hard end has passed is `401 invalid-credentials` on every route: no create, no status, no revoke and no list (`GET /v1/me/secrets` is `401` too), but the secrets it made still reveal to their own TTL; manage them with their manage tokens. Revoking a lease (`DELETE /v1/me/key`) takes effect within 5 seconds everywhere: its unrevealed tokens answer `410`, revealed ones stay revealed, receipts stand, and its manage tokens still read status (`dead`) and revoke | per key |
| Passphrase on reveal | yes | yes | yes | yes |
| Client-side encryption (no-view) | yes, via the SDK | yes | yes | yes |
| Reveal receipt (signed, verifiable offline) | yes | yes | yes | yes |
| Status (`GET /v1/secrets/{token}/status`) | with the manage token | yes | yes | yes |
| Delegation (`POST /v1/me/key/delegate`, one level, narrower) | no | no | leases with the `delegate` scope (planned) | yes (planned) |

The per-address counters count an address, not a caller: callers behind one NAT or one shared egress address share
them (an IPv6 caller is counted by its /64). The per-network counters cover the whole IPv4 /24 or IPv6 /48 around that
address, so neighbours on one network share those too.

## How limits change

Limits are policy, not part of the API's compatibility promise: the shapes of
calls never narrow inside `/v1`; the numbers in the table can. A change is published here with a dated line and at
`GET /v1/limits` 30 days before it applies. A response to abuse (the first-month review)
applies at once and is published the same day. Secrets already stored keep their size and expiry.

## How a limit shows up

```http
HTTP/2 429
content-type: application/problem+json
retry-after: 2
ratelimit-limit: 5
ratelimit-remaining: 3
ratelimit-reset: 2
ckrets-tier: anonymous

{ "type": "https://ckrets.ai/problems/rate-limited",
  "title": "Wait before the next attempt",
  "status": 429,
  "limit": "revealAttempts",
  "retryable": true,
  "retryAfter": 2 }
```

```http
HTTP/2 429
content-type: application/problem+json
ratelimit-limit: 300
ratelimit-remaining: 0
ratelimit-reset: 46800
ckrets-tier: anonymous

{ "type": "https://ckrets.ai/problems/quota-exhausted",
  "title": "No creates left today",
  "status": 429,
  "limit": "createsPerDay",
  "retryable": false,
  "resetsAt": "2026-10-25T00:00:00Z",
  "detail": "Mint a free key at POST /v1/keys to continue now." }
```

The hour's refusal has the same shape: `limit` `createsPerHour`, title "No creates left this hour", `resetsAt` the next
whole hour. A network's ceiling names `netCreatesPerHour` or `netCreatesPerDay`. A key's names `createsPerDay`. None
carries `Retry-After`: each is a stop until `resetsAt`, not a slow-down.

A create the API refuses as invalid (`400`, `413`) is checked before anything is counted, so it spends none of your creates;
a create refused by one limit (`429`) gives back what the others took, so it spends nothing either; a
throttled one (`503`) gives back what it took. An agent that reads `RateLimit-Remaining` on each response never needs to see a `429`. An agent that plans more than a
few calls should call `GET /v1/limits` first, or mint a free key with `POST /v1/keys`. SDK default retry policies treat
every `429` as transient; ours says which it is, so a client that honours `retryable` never retry-storms a spent quota.

**At the door, before the API** (measured in a load test): the edge refuses more than **300 creates a
minute from one address** (`POST /v1/secrets` and `POST /mcp` together) with a plain `403` (no problem body, no `RateLimit-*` headers), so one address bursting creates
meets the `403` before any `429`; a batch belongs in `POST /v1/batches`, not a loop. Under a flood the anonymous tier
**pauses** (`503 tier-budget-exhausted` with `Retry-After`) while keyed agents keep working (14 of 14 creates and reveals
beside a flood of 108,000 creates a minute): an agent that matters holds a free key. The edge has a second rule: more
than **100 requests a minute from one address to one `/v1/secrets/…` path** (a reveal, a status, a public view) is the
same plain `403`. Every request counts toward these two, whatever its outcome and whoever sends it: the door counts by address and never
sees a key, so keyed and CI callers behind one address count together. A door `403` is CloudFront's own page, never
`application/problem+json`: an API `403` always has a problem body with a `type`. A door `403` lifts once the
address's rate falls back under the limit (a minute or two); wait, then slow down.

`RateLimit-*` on a reveal describe the token's attempt budget (`limit` follows the creator's tier: 5 for a secret made
anonymously, 10 for one made with a free key; `remaining` attempts left, `reset` the seconds
of cooldown left). On every other route they describe the caller's tightest create limit (an anonymous address's hour or
day, a key's day). There is no in-flight figure.

Only an anonymous create is ever refused for the tier's daily budget (on `/v1` and `/mcp`) or for a stamp (on `/v1`).
While the anonymous tier is paused, reveals, status, revokes, `/public`, key mints, reports, `GET /v1/limits` and
`GET /v1/me/key` all still work, and none of them needs a stamp. Of these, `GET /v1/limits`, status, `/public` and
`GET /v1/me/key` also spend nothing.

What each call spends, in one place:

| Call and answer | Spends |
|---|---|
| a create, `201` | one create from each of your counters (address, network; or the key's day) |
| a create replayed with its `Idempotency-Key`, `201` | nothing |
| a create refused (`400`, `413`, `415`, `428`, `429`, or a `503` that did nothing) | nothing |
| a create answered `503` with `outcome: "unknown"` | one create, made or not: it is not given back |
| a reveal, `200` | one of the token's reveals |
| a reveal with a wrong passphrase, `401` | one of the token's attempts; a denial, a deny receipt, and the cooldown starts |
| a reveal with no passphrase where one is needed, `401` | nothing |
| a reveal with a wrong or missing audience proof, `403` | no attempt; a denial and a deny receipt |
| a reveal inside the cooldown, `429` | nothing |
| a reveal after the last one, `410` | nothing of yours; a `consumedAttempts` on the record, an alarm for the creator |
| any other `410`, and every status, `/public`, limits or key read | nothing |
| a report, `202` | one of 10 reports an hour from your address (a second report of the same token counts once) |
| a mint, `201` | one of your address's mints (10 an hour, 30 a day) and of its network's (100 a day) |

## Errors, retries and headers

Every error from the API is `application/problem+json` with `type` (a stable URI: branch on it), `title` (for people;
it varies with the cause, so never parse it), `status` and `requestId`. `retryable` and `retryAfter` appear on every `429` and
`503`, and on the two `409`s about an `Idempotency-Key` (`idempotency-in-progress`: `retryable: true`, with `Retry-After`;
`idempotency-key-retired`: `false`); any other status has neither and is final for that request: change something before
you send it again. Every type,
with what to do:

| Answer | Do |
|---|---|
| `429 rate-limited` (`Retry-After`, `retryable: true`) | wait `Retry-After` seconds and send it again; after three in a row on the same call, stop and read `GET /v1/limits` |
| `429 quota-exhausted` (no `Retry-After`, `retryable: false`) | stop until `resetsAt`, or mint a key |
| `503 tier-budget-exhausted` (`retryable: false`, `Retry-After`) | the anonymous tier is paused: `Retry-After` is the seconds until it reopens at 00:00 UTC, not a hint to retry sooner; a key works now |
| `503 temporarily-unavailable`, `retryable: true` (`Retry-After: 1`), no `outcome` | nothing was done; send it again after a second. The other `503 temporarily-unavailable`, with `outcome: "unknown"`, carries `Retry-After` too: tell the two apart by `outcome` in the body, never by the header |
| `503 temporarily-unavailable`, `outcome: "unknown"` (`retryable: false`) | a write may have happened: read the call in `check` first (a lost reveal is worked through in [Limits](/limits.md#errors-retries-and-headers)). The other `503 temporarily-unavailable`, without `outcome`, means nothing was done. `check` is `none` for a create (create again; the first, if it was made, expires unrevealed: nobody holds its token, so nobody can reveal it and the value is not delivered twice), `GET /v1/secrets/{token}/public` for a reveal (`state` says whether it was spent; if it was and the answer was lost, the value is gone: create again), a claim or a puzzle open, `GET /v1/secrets/{token}/status` for a revoke or a policy change |
| `503 key-unavailable` (`retryable: true`, `Retry-After: 60`) | a reveal: the key that opens this secret is unavailable on our side. Nothing was spent (the secret, its reveals and its attempts are as they were; on a Strict riddle box whose commitment was already checked, that one attempt stays counted); try again after `Retry-After` |
| `400 invalid-request` | the request is malformed: bad JSON, a missing or empty `value`, a field of the wrong type or out of range (`detail` names it); fix it |
| `400 ttl-too-long` | the `ttl` is over the tier's maximum; shorten it or use a key |
| `400 audience-type-unknown` | the audience's `type` is not one this version takes: it takes `key`, `oidc` and `publicKey`; `httpsig` is planned with the signed-agent tier, and an object or array with no `type` (`{}`, `[]`) gets this answer too. Nothing was made. Use one of the three types, or leave `audience` out for a bearer link |
| `400 audience-invalid` | the audience has a type this version takes but names no single recipient: an OIDC audience whose claims would match more than one job. Name the job: `sub`, or on GitHub Actions `repository` (or `repository_id`) with `ref` or `job_workflow_ref`; under GitLab or CircleCI `sub` is required and `repository` or `job_workflow_ref` is refused, since their tokens never carry them. Nothing was made. (A malformed key id or public key is `400 invalid-request`.) |
| `413 value-too-large` | the value is over the tier's size; use a key (64 KB) or split it |
| `409 idempotency-mismatch` | that `Idempotency-Key` was used with another request; nothing was made, but it counts as one create; use a new key |
| `409 idempotency-key-retired` | that `Idempotency-Key` was used with three different requests and is retired: every request with it is refused, the right one included; a secret already made with it stands; use a new key |
| `409 idempotency-in-progress` (`retryable: true`, `Retry-After: 1`) | a create with this `Idempotency-Key` is still running: send the same request again after `Retry-After`; it then gets the first answer |
| `401 passphrase-required` | the token needs its passphrase: `POST` it as `{"passphrase": "…"}` (a wrong one is counted) |
| `403 audience-mismatch` | you are not the named recipient, or sent no proof; not counted; do not retry with the same credential |
| `403 scope-missing` | the key lacks the scope this call needs; use the manage token for status and revoke, or a key with the scope |
| `403 not-creator` | status and revoke need the creator's credential or the manage token |
| `404 not-found` | no such route, or (status, `/public`) no such token. A method a `/v1` path does not take (`PUT /v1/secrets`, `HEAD` on a reveal) is this `404` too: the API never answers `405` |
| `410 token-gone` | the token will not reveal again: it was revealed for the last time, expired, revoked (or its creator's key was), or never existed. Never retry; ask the sender for a new one. (A request page that is no longer open answers the same) |
| `410 attempts-exhausted` | the token's passphrase attempts are used up (5 anonymous, 10 with a key): it will never reveal, even with the right passphrase. Never retry; ask the sender to create it again |
| `410 token-frozen` | the token was reported and is held for review; never retry or report it again: ask its sender for a new token (a review may lift the freeze, but v1 promises no time for it) |
| `428 proof-of-work-required` | solve the `stamp` in the body and resend with `Ckrets-Proof` (anonymous creates only) |
| `501 not-in-v1` | the path is planned (specified, not built yet); do not retry |
| `410 feature-sunset` | the feature you called was deprecated (announced in `/.well-known/ckrets.json` `deprecations` and `/llms.txt` at least six months before, with `Deprecation` and `Sunset` headers) and has now ended; use the replacement the deprecation names, and do not retry |
| `500 internal` | our fault; nothing to fix on your side: report the `requestId`, and do not loop on it |
| `403 wrong-host` | the request reached the API's own address instead of `https://ckrets.ai`: send the same request to `https://ckrets.ai` (the door's WAF and limits apply only there) |
| `400 plaintext-refused` | the key requires client encryption (`clientEncryptionRequired`, set at mint or with `PATCH /v1/me/key`) and this value would reach ckrets in the clear: encrypt it on your side and send `clientEncrypted: true` with its `wrappedKey` ([client encryption](/docs/client-encryption.md)); the helper and the SDKs do it. Through MCP, use the local helper (`npx @ckrets/mcp`), not the hosted server. Nothing was made or counted |
| `400 version-unknown` | the `Ckrets-Version` header names a date this server does not serve; `supported` (and `detail`) list the dates it does. Send one of them, or no header for the original contract |
| `415 unsupported-media-type` | a write (`POST`, `PATCH`) must be `Content-Type: application/json`, or have no body and no type; send JSON. Parameters (`; charset=utf-8`) and letter case are ignored, and any `application/…+json` type is taken; anything else, `application/json5` included, is `415`. A create with no body at all is `400 invalid-request` (no `value`) |
| `401 invalid-credentials` | the `Authorization` header is not a valid credential (unknown, revoked, ended or malformed, or not `Bearer <credential>` at all: `Basic`, `Token`, a bare `Bearer`; the word `Bearer` itself may be in any case). It is refused on every route, even one that needs no credential (a bearer reveal, `GET /v1/limits`): send no header rather than a bad one. A well-formed manage token (`ckm_` and 26 uppercase Crockford base32 characters) is not an invalid credential, even one we never issued: everywhere but its own secret's status, revoke and policy it counts as no credential, so the caller is anonymous (a create with it makes an anonymous secret, `201`; `GET /v1/limits` answers the anonymous tier, `200`; `/v1/me/key`, which needs a key, answers `401`). A manage token that is not its secret's is `403 not-creator` on status and revoke. A `ckm_` of any other form is `401 invalid-credentials` |
| a `403` with no problem body | the door (above): wait a minute or two, then slow down |
| a `503` with no problem body and no `Retry-After` | the platform in front of the API (its own small JSON body). A read: nothing was done; wait 2 seconds and send it again. A write (a create, a reveal, a revoke): usually nothing was done, but one that ran past the platform's 15-second limit may have happened: check as for `outcome: "unknown"` first |

The first-party apps' routes (Puzzlebox's boxes, `template: puzzle`) and later versions add these:

| Problem | What to do |
|---|---|
| `425 not-yet-open` | a dated box before its `opensAt`: come back at `resetsAt`; nothing was spent |
| `401 proof-required` | a box needs a proof you did not send or that was wrong (`result: "wrong"` counts an attempt; the body says how many are left and the cooldown); a `403` with this type is a failed Turnstile check: solve it again and send the fresh token |
| `401 ticket-invalid` | the attempt ticket is malformed, expired, for another box, or already used or voided: claim a new attempt for a fresh ticket |
| `401 verdict-invalid` | the judge's verdict does not match this ticket, box or judge: ask the judge again with the ticket you hold |
| `401 session-invalid` | an anonymous puzzle create needs a session ticket: `POST /v1/sessions` with a Turnstile token, then send `Ckrets-Proof: session=<ticket>` |
| `409 policy-widening` | a policy change may only tighten a box's gate (fewer attempts, a later `opensAt`, a longer cooldown, `revealOnce` on); its window may be extended up to the tier's maximum but not shortened (revoke instead): `detail` names what was refused; nothing changed |
| `422 hint-out-of-range` | there is no hint at that index: `hintCount` in the body says how many the box has |
| `403 void-not-allowed` | voiding or judging a ticket is for the app's own layer, through its private API only |
| `422 webhook-unverified` | (planned) the webhook's host is not verified for this key: verify the domain first |
| `422 domain-unverified` | (planned) the verification file was not found at the path or held the wrong token: serve it exactly and call verify again within 24 hours |
| `413 batch-too-large` | (planned) the batch is over its item or size limit: split it |

A reveal is not idempotent. Each reveal is claimed by one conditional write: with N reveals left, exactly N of any
number of callers at once get the value, and the rest get `410`. Once its claim is written the reveal is spent, even if
the answer never reaches you (a dropped connection, a timeout, a `200` you never read), and nothing fetches a value
again. The value's key and the receipt are made ready before the claim, so a failure in either spends nothing (`503
key-unavailable` or a retryable `503`), except on a Strict riddle box, where an attempt whose commitment was already
checked stays counted (never the box). Past the claim nothing else can fail the reveal: a value whose deletion fails is
deleted by the next hourly sweep. After a `503` with `outcome: "unknown"`, never resend blindly: read the call in `check` first, then send it again
at most once, and only if the check says nothing was spent. A reveal whose answer was lost, worked through:

```http
HTTP/2 503
content-type: application/problem+json
retry-after: 1

{ "type": "https://ckrets.ai/problems/temporarily-unavailable",
  "title": "Busy: the outcome is unknown",
  "status": 503,
  "retryable": false,
  "outcome": "unknown",
  "check": "GET /v1/secrets/{token}/public",
  "retryAfter": 1 }
```

Read `GET /v1/secrets/{token}/public` (it reads consistently, as status does, so a claimed reveal always shows).
`revealsLeft` the same as before your reveal: nothing was spent; reveal again, once. `revealsLeft` lower, or `state`
`revealed`: the reveal was spent and its answer lost, so the value is gone; ask the sender to create again. On a secret
with several reveals, another recipient may have revealed meanwhile, so `/public` cannot say whose reveal it was: only
the creator's status (`revealedAt`, and `revealedBy` for an audience) says who. A puzzle claim or open is checked the
same way, by `/public`'s `state` and `attemptsLeft`; the same commitment sent again answers as the first time and spends
nothing.

The puzzle template and the first-party apps have their own types (`not-yet-open` 425, `ticket-invalid`,
`verdict-invalid` and `session-invalid` 401, `proof-required` 401 or 403, `policy-widening` 409, `hint-out-of-range`
422, `void-not-allowed` 403); the planned calls add `batch-too-large`, `webhook-unverified` and `domain-unverified`. A type you do
not know: treat it by its status.

**Which refusal comes first.** Every call: a write that is not JSON (`415`), then a bad `Authorization` header (`401 invalid-credentials`), then a key's missing
scope on a reveal, status or revoke (`403 scope-missing`). A create: its form (`400`, `413`) before any limit (`503`,
`428`, `429`); a key without `create` is `403` after the form checks and before any limit, so it spends nothing. A reveal: a token that will not reveal (`410`), then
the audience proof (`403`), then the cooldown (`429`, the same answer whatever passphrase is sent, right or wrong,
and nothing counted), then the passphrase (`401`). So a token with an audience and a
passphrase answers `403` to a stranger whatever passphrase is sent, and a token that is gone (revealed, expired, revoked,
frozen or out of attempts) answers `410` to everyone before any proof is read: that `410` records no denial and no deny
receipt, whatever proof was sent.

Every response from the API carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Ckrets-Tier` and
`Ckrets-App` (the door's `403` and the platform's `503` carry none), and every answer from the API carries `Ckrets-Request-Id`, the id to quote in a report (a problem's `requestId` is the same). A key minted with `clientEncryptionRequired: true` (or switched on with `PATCH /v1/me/key`; it cannot be switched off) refuses every value it would hand over in the clear with `400 plaintext-refused`: plain creates, creates through the hosted MCP server, and requests (their answers are sealed by ckrets). Every create's `201` carries `Ckrets-Encryption`: `client` when the value arrived encrypted on your side (`clientEncrypted: true`), `server` when ckrets sealed it, which means ckrets could read it; the receipt's `ce` claim says the same. `HEAD` answers like `GET` (the same status and headers, no body) on `GET /v1/limits`, `/.well-known/jwks.json` and the public views (`/v1/secrets/{token}/public`, `/v1/requests/{requestId}/public`), for uptime probes; never on a reveal. Every answer is `Cache-Control: no-store` and the
CDN never caches `/v1/*`, so a reveal is never served from a cache. Browsers on other sites get no CORS grant, on `/v1` and `/mcp` alike (only the first-party apps' own origins are
allowed; agents are not browsers), and a write must be JSON, so no web page can make a visitor's browser create, mint
or call `/mcp` for it.

Every answer also carries `Ckrets-Version: YYYY-MM-DD`, the dated contract it follows (`2026-11-06`, the beta launch
date, is the first and only one). A request may send `Ckrets-Version` to name the contract it was written for; a
date this server does not serve is `400 version-unknown`, listing the dates it does. **A request without the header
gets the original contract.** A new date comes only with a breaking change (an added field, value or call never makes
one), it is announced in `/changelog.md`, and an old date is served for at least 12 months after the next is
published. The path stays `/v1`. The helper, the SDKs and the MCP tools send the date they were built against.

## Requests to a person

`POST /v1/requests` makes a page (`/q/<requestId>`) that asks a person for one value on behalf of a key. Anyone with a
free key can make one, so the page is treated as a phishing surface and these limits are tighter than a create's:

| Limit | Value |
|---|---|
| Who may ask | a ckrets key (a free key or lease; a paid key once paid keys exist); never anonymous |
| Requests per key | a free key or lease: 5 an hour, 20 a day, 3 open at once; a paid key: 20 an hour, 100 a day, 5 open at once. Past the hour or the open count: `429 rate-limited` with `Retry-After`; past the day: `429 quota-exhausted` |
| Prompt | at most 140 characters, one line, no link, domain or email address (`400 invalid-request` naming why) |
| Open for | `ttl` 10 minutes by default, at most 1 hour (`400 ttl-too-long`) |
| Answers | one per request. The page takes answers only from itself (same origin), at most 10 an hour from one address (`429 rate-limited`). An answer that does not fit the kind is `400` (or `413`) and the request stays open; the third closes it (`expired`) |
| Answer sizes | `otp`: 4 to 12 letters or digits; `password`: one line, up to 1,024 bytes; `text`: up to 4 KB |
| Encrypted answers | send `encJwk` (your X25519 public key) and the page encrypts the answer in the person's browser to it (`hpke.v1`) before sending: ckrets stores ciphertext it holds no key for, and you reveal it with your private key. The page checks the answer's shape first, since ckrets can't; a plain answer to such a request is `400`. Without `encJwk` ckrets seals the answer and could read it. A key with `clientEncryptionRequired` must send `encJwk` (`400 plaintext-refused`) |
| What an answer costs | one create from the asking key's day; it becomes a secret for that key, one reveal, one hour |
| Reports | a report (`POST /v1/report` with the page's URL) freezes the request at once; after two reports a key's further requests are held for review (`429 quota-exhausted`, "held for review"); its secrets are unaffected |

The page always shows the same notice above the input, which no request can change: "This request was made by an API
key, not by ckrets. ckrets never asks for your email, bank or ckrets passwords. Only answer if you know who is asking
and why." Then the key's label, its domain (marked unverified in v1) and its id. The prompt is kept with the request
only (for its own life and the 30 days after), and never written to our logs.

## The proof-of-work stamp

On the HTTP API, anonymous tier only. A create normally needs none. When the tier is busy (more than 20 anonymous creates a second across the whole tier, whoever sends them), a create without a good stamp answers `428` with problem type `proof-of-work-required` and a fresh `stamp: {alg: "sha256", difficulty, nonce, expiresAt}` in its body (one is also in `GET /v1/limits`). Every `428` carries a new stamp with a new `nonce`, never the one you sent: solve the stamp in the latest answer. Find a `counter` (a decimal number from 0, at most 15 digits) such that `sha256(nonce + ":" + counter)` has `difficulty` leading zero bits, then resend the create with the header `Ckrets-Proof: stamp=<nonce>:<counter>`. The difficulty is 11 bits by default (about 10 ms in Node); it never changes with load: only the operator sets it, as a limit, so a change is announced like any limit (on /limits.md and in `GET /v1/limits`, 30 days ahead, except in answer to abuse). Always read `difficulty` from the stamp you were given, never hard-code it. A stamp is good only at the difficulty it was issued with, so a change voids outstanding stamps. The stamp's network is narrower than the per-network counters' on IPv6 on purpose: a stamp is bound to what one
caller holds (an IPv6 /64, the unit a per-address limit counts; an IPv4 /24, so an egress pool's rotation keeps it), while
the counters use a /48 to catch a pool spread over a whole site's subnets. A stamp is not single use: it is good for any number of creates until its `expiresAt` (120 s after issue), from the network it was issued to (IPv4 /24, IPv6 /64, so a rotating egress pool keeps it), each create still counted. Any stamp that fails (missing, malformed, too weak, expired, from another network, or issued before a difficulty change) is answered the same way: `428` again with a new stamp, never `400`. When the tier is not busy a stamp is not read at all, so sending one does no harm. One caller over its own rate gets `429`, not `428`: the `428` depends only on the tier's load. Signed and keyed callers never need a stamp. On `/mcp` there is no stamp; the anonymous tier there has the same rates.

## What a create checks

Every check below runs before anything is counted or spent, so a refused create costs nothing. Nothing is ever clamped
to fit: a request over a limit is refused, never shortened.

| Field | Rule | Refused with |
|---|---|---|
| `value` | a JSON string of at least one character (`" "` is accepted). **Size is counted in UTF-8 bytes** of the string after JSON escapes are read, not in characters and not the bytes of the request body: `"é"` and `"\u00e9"` are both 2 bytes. 1 KB is 1,024 bytes: at most 1,024 anonymous, 65,536 with a key. A client-encrypted value is measured the same way, as its ciphertext string, so it must fit after encryption (in the format of `/docs/client-encryption.md`, at most 737 bytes of plaintext anonymously and 49,121 with a key); over the size it is `413` like any value. `wrappedKey` does not count. Binary goes in as base64 text, so 1 KB holds 768 raw bytes and 64 KB holds 49,152. The value is stored and returned exactly as sent: no trimming and no Unicode normalisation, and NUL, CR and LF, tabs and invisible characters come back byte for byte | missing, empty (`""`) or not a string: `400 invalid-request`; too big: `413 value-too-large` |
| `ttl` | one whole number and one unit: `<n>m`, `<n>h` or `<n>d` with a lowercase unit, or an ISO 8601 duration `P[nD][T[nH][nM][nS]]` with uppercase letters. At least 60 seconds; the tier's maximum is allowed (7 d anonymous, 365 d with a free key); default 7 d; `"never"` (no expiry) is a paid key's and `ttl-too-long` on every other tier. The form is checked before the maximum, so each of these is malformed, `400 invalid-request`, even when it would also be over the maximum: combined shorthand (`1h30m`, `4h1m`, `4h1s`), a fraction (`1.5h`, `PT0.5S`), seconds shorthand (`60s`), the wrong letter case (`15M`, `1H`, `pt1m`), years, months or weeks (`P1Y`, `P1M`, `P1W`). A well-formed TTL over the maximum (`8d` or `P7DT1M` anonymous) is `ttl-too-long`. Zero, negative or under a minute is refused, never made to expire at once | malformed or under a minute: `400 invalid-request`; well-formed but over the maximum: `400 ttl-too-long` |
| `reveals` | an integer from 1 to the tier's maximum: 1 anonymous, 10 with a key (the schema's 1,000 is the paid tier's). It is a count, not a list of recipients: every reveal counts whoever makes it, so one recipient may take them all | `400 invalid-request` |
| `passphrase` | 1 to 256 characters, counted as Unicode code points, as JSON Schema's `maxLength` counts: not bytes, UTF-16 units or graphemes (😀 counts 1, the flag 🇫🇷 counts 2). At reveal it is compared exactly as sent: case-sensitive, with no Unicode normalisation (`é` as one code point and as `e` plus an accent are different passphrases); it is kept only as a salted scrypt hash and compared in constant time, as is the manage token, and tokens are looked up by their hash | `400 invalid-request` |
| `purpose` | at most 140 Unicode code points (not bytes, UTF-16 units or graphemes; counted as for `passphrase`) | `400 invalid-request` |
| `audience` | absent or `null`: a bearer secret (anyone with the URL reveals it); otherwise an object whose `type` is `key`, `oidc` or `publicKey` (`/for-agents.md`, section 2). A `201` echoes `audience` only when one was set, so a `201` without it made a bearer secret | not an object (a string, a number, `true`), a malformed key id or a bad public key: `400 invalid-request`; an object or array with no `type` this server takes (`{}`, `[]`, `httpsig` until the signed tier is built): `400 audience-type-unknown`; OIDC claims that name no one job: `400 audience-invalid` |
| puzzle `hints`, `rubric` | at most 5 hints of at most 280 characters each; a game box's rubric at most 800 (code points, as for `passphrase`); never cut, so the judge reads the whole rubric | `400 invalid-request` |
| any string | well-formed Unicode: a lone surrogate (`\uD800` with no pair) is refused, never replaced | `400 invalid-request` |
| `public` | the puzzle template's public metadata; on a `onetime` create it is ignored: not stored, never shown at `/public` | |
| anything else | refused, never ignored: a create takes only `value`, `ttl`, `reveals`, `passphrase`, `purpose`, `audience`, `clientEncrypted`, `wrappedKey`, `template` and `public`, so a misspelt field (`audeince`) makes nothing. A puzzle create takes `template`, `value`, `clientEncrypted`, `wrappedKey`, `purpose` (the box's title: at most 140 code points, shown to its owner, never at `/public`), `public` and `puzzle`: its expiry is `puzzle.window`, so a top-level `ttl` is refused there | `400 invalid-request`, naming the field |

The TTL starts at create: `expiresAt` is the create's time plus `ttl`, cut down to the whole second (so a TTL can run up
to a second short, never long); a reveal never extends it. A token reveals while the
service clock is before `expiresAt`; at `expiresAt` it is `410`. The claim's write decides: a reveal claimed before
`expiresAt` returns the value even if the answer leaves after it.


## Why these numbers

Content is never inspected (the platform is content-blind, and no-view when the client encrypts), so the limits are the
control. The anonymous tier is sized to what a one-time hand-off needs and no more: 1 KB holds any credential, 7 days
covers a hand-off that will be collected this week, and one reveal is the product. Those caps also make the tier a poor staging
post for anything else, which is the lesson of every anonymous drop box before it. The per-IP and per-key rates bound
what one caller can do; the tier budget bounds what the anonymous tier can cost; the attempt cap and cooldown make a
passphrase worth having; audience binding is available on every tier because a token only its recipient can open is
worth nothing to anyone else. The signed-agent tier exists so that a cooperative agent can be accountable without an
account: its operator's domain publishes the key. The numbers are a starting point and will be tuned from the first
month's measurements; changes are announced with a date in `/changelog.md` (linked from `/llms.txt`; a status page at `/status` is planned). If the anonymous tier is abused,
the next step is that minting a key needs a passkey-signed human or a signed agent; the anonymous call itself stays
small and fast to take down by token.

## Apps on the same platform

The ckrets.ai API is the unlock service under every product. Limits are rows per `(app, tier)`:
the table above is the agent API's (`app: api`, the default). First-party apps call the same API from their own
domains through a CORS allow-list, send `Ckrets-App`, and bring their own anonymous proof (`Ckrets-App` counts only on an app's own routes: on a plain create, keys, report, status or revoke it is ignored and the answer is `api`'s); `GET /v1/limits` returns
the row that applied, and every response carries `Ckrets-App` beside `Ckrets-Tier`.

| App | Anonymous proof | Tiers (set by that product's card) |
|---|---|---|
| `api`: agents, CI, people at ckrets.ai | the proof-of-work stamp when the tier is busy across many addresses (`Ckrets-Proof: stamp=…`), or a signature or key | anonymous, signed agent, free key, paid (this page) |
| `puzzlebox`: pleaseunbox.com | Cloudflare Turnstile, solved once for a session ticket (`POST /v1/sessions`: 10 minutes, one create, up to three generator calls; `Ckrets-Proof: session=<ticket>`), verified by the API | anonymous (2 KB, 7 days after it opens, one box a day per address), passkey (16 KB, 30 days), Plus |

In v1 the puzzle template (riddle, game and dated boxes) belongs to first-party apps (`Ckrets-App: puzzlebox`); opening it to agents on the `api` rows is a later addition. A
puzzle box's attempts follow its own policy: each claim counts when made, hints cost an attempt each (every claim of one, the same hint again included), and claim n+1
waits 2^(n-1) seconds after claim n (1, 2, 4, 8, 16 s) until the box opens; a cooldown refusal is not counted.

## Alarms

These events are visible in status, and, once webhooks are built (planned), also delivered to keyed and signed callers as signed webhooks: a reveal
attempted after the last permitted one (`secret.consumed_attempt`), a secret that expired without being revealed
(`secret.expired_unrevealed`), a denied reveal (`secret.reveal_denied`: wrong passphrase, wrong audience), and a
revocation. They are the two questions a sender has after an incident: did the right party get it, and did anyone else
try.

## What we keep, and for how long

Never the value after its last reveal or expiry, and never a value the client encrypted. The Public API is what runs
today; the Enterprise API is planned, and its column is what it will follow, not something it does yet.

| What | Public API | Enterprise API (planned) |
|---|---|---|
| The value | until the last reveal, a revoke, or expiry (an hourly sweep removes an expired value within the hour, and a revealed one whose deletion failed; the record without it answers status for 30 days) | kept until the customer deletes it or the expiry the customer sets: retention is the customer's choice, and the default is to keep |
| The secret's record (what status and the manage token read) | until 30 days after `expiresAt`, then status is `404` | as long as the value, then for the audit periods below |
| Token metadata: creation time, creator (key id, signing key, CI identity, or a hash of the address), size, TTL, policy hash, reveal and denial times, the revealer where an audience was set | 13 months | part of the audit trail: at least 12 months, the last 3 months immediately available (PCI DSS requirement 10.5.1); longer by contract |
| Receipt `jti` and keyed digest, so a receipt can be checked against our record | 13 months | the same as the audit trail |
| The access record: raw IP address, browser, passkey id, payment reference; readable only for a lawful request or an abuse report | 30 days | the audit trail of every access and administrative action: at least 12 months, the last 3 months immediately available (PCI DSS requirement 10.5.1); 6 years on the health tier (45 CFR 164.316(b)(2)(i)); longer by contract |
| A key's record: its id, the key's hash, label, scopes, lease times, the minting address's hash, and the email if one was given (never verified; no mail is sent in v1, and v1 has no key recovery: a lost key is gone, mint another) | until 31 days after the lease's hard end (at most 38 days from minting) | for the contract, then the audit periods above |
| An `Idempotency-Key` record: the key's hash and scope, a keyed fingerprint of the request (not reversible without a service secret) and the create's answer, sealed (it holds the token and manage token) | 24 hours from the create; after that the key makes a new secret, and the row is deleted within days | the same |
| The measurement stream | counts by tier and limit, no addresses | the same |

The Enterprise API's logging follows PCI DSS, HIPAA and SOC 2 rules: a complete audit trail of every access and
administrative action, protected from change, kept for the periods those rules require, under a contract (a BAA for
health data). It is planned: the health tier opens in early 2027 and PCI DSS validation is upcoming.

Logs never hold a value, a digest key, a passphrase, a key, a CI OIDC token, the `Authorization` header, a manage token
or a token: the API's request log records no headers, no path and no query (only its route key), and an error log keeps
only the path, with any token in it masked. The CDN and the door in front of the API keep no access log, so no token in
a path is logged at the edge either. `/mcp` keeps no session: the `Mcp-Session-Id` it hands out is random and never stored.
The address hash in a secret's record uses one service-wide secret salt, which is not rotated, so one address hashes
the same way for the 13 months the metadata is kept. This metadata is what a lawful request or an abuse report gets.
Apps built on ckrets publish their own retention.

### When deleted means deleted

Two promises, each with its bound:

- **One secret.** A revoke or the last reveal removes the value at once: no reveal can return it after that answer.
  Copies leave our backups within 7 days (the tables' point-in-time recovery window).
- **A whole account.** At contract end the account's data key is deleted. Every running instance stops being able to
  open its secrets within the key cache's lifetime: about 5 minutes when ckrets holds the key, about 60 seconds when the
  customer does. Backups clear within 7 days. When the customer's own KMS key is destroyed, AWS's record of that is the
  proof.

That cache lifetime is also how long a revoked or disabled key takes to stop working everywhere.

Each value is sealed under a data key of its own, made from the account's data key, which our KMS key wraps. Every
stored item names its key version and algorithm, so a key can be rotated (new values seal under the new version, and a
background pass moves the rest) without a secret becoming unreadable. A secret's status shows `encryption.custody`
(`ckrets` today) and `encryption.dedicatedKey`; neither can be set on a create.

## Prohibited uses and reports

Malware staging, exfiltration of data you are not entitled to move, anything unlawful where you or the recipient are.
`POST /v1/report` takes a token or URL and a `reason` (required: at most 2,000 characters; without one it is `400`) and
answers a case id, the same whether or not the token exists. A bearer token is frozen pending review (a review lifts it),
so anyone holding its URL can freeze it: name an audience when that matters. In v1 there is no call to lift a freeze or
to ask for a review, and no review time is promised: a sender whose token was frozen revokes it with the manage token
and sends a new one. An audience-bound token is recorded, not
frozen, since its audience already keeps strangers out. In v1 the creator sees a report in status (`reports`, `frozen`,
`lastCase`); webhooks are planned. One report per token per address counts once; 10 reports an hour per address.
Takedown is by token. The beta [terms](/docs/terms) and [privacy page](/docs/privacy) say the rest. The points of contact
for the EU Digital Services Act and the UK Online Safety Act are not published yet; until they are, reach us about
content with `POST /v1/report`.
