Limits
The tiers at a glance
Anonymous
no key
- Value up to
- 1 KB
- Keeps it up to
- 7 d
- Reveals per secret
- 1
- Creates
- 240 an hour, 1,200 a day per network (IPv4 /24, IPv6 /48), and within that 60 an hour, 300 a day per IP
- Ask a person
- no
Signed agent planned
- Value up to
- 4 KB
- Keeps it up to
- 7 d
- Reveals per secret
- up to 3
- Creates
- 300 an hour, 3,000 a day per signing key
- Ask a person
- planned
Free key
- Value up to
- 64 KB
- Keeps it up to
- 1 year (365 d)
- Reveals per secret
- up to 10
- Creates
- 1,000 a day
- Ask a person
- 5 an hour, 20 a day, 3 open at once
Paid key later
- Value up to
- 1 MB
- Keeps it up to
- no maximum
- Reveals per secret
- up to 1,000
- Creates
- by plan
- Ask a person
- 20 an hour, 100 a day, 5 open at once
Every TTL defaults to 7 days. The full table, every rule and the headers follow.
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.
Scroll the table sideways: one column per tier.
| 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 429s 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/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/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:
Scroll the table sideways.
| 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 409s 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:
Scroll the table sideways.
| 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). 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); 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:
Scroll the table sideways.
| 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/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:
Scroll the table sideways.
| 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.
Scroll the table sideways.
| 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.
Scroll the table sideways.
| 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.
Scroll the table sideways.
| 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 and privacy page 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.