Rate limits
Two ceilings apply to every call, one per client IP and one per identity from your plan, and both answer 429 with a retry-after header.
Every call passes two counters. The first counts requests from your network address, the second counts requests from your identity. Both reset on a fixed one minute window, the way a car park counts cars through the barrier rather than how long each one stayed.
The IP ceiling
1000 requests per minute per client IP, counted before your credential is even read, so a flood of
junk bearer tokens never reaches a database lookup. The number is IP_REQUESTS_PER_MINUTE in
apps/web/src/server/ip-rate-limit.ts. The client IP is the first hop of x-forwarded-for.
This ceiling caps abuse, and it does not meter real use. It sits well above a single pipeline, a
whole CI egress range or a NAT with many identities behind it. The exchange routes under
/api/v1/auth are covered by this ceiling only, because they have no identity yet.
The per-identity ceiling
After authentication, the request is counted against the identity that the credential names, in two independent buckets.
| Bucket | Which calls land in it |
|---|---|
read | GET /api/v1/secrets, GET /api/v1/secrets/{address}, GET /api/v1/bulk-read-secrets, POST /api/v1/dynamic/{address}/lease |
write | PUT /api/v1/secrets/{address}, DELETE /api/v1/secrets/{address} |
The ceiling comes from your plan.
| Plan | Reads per minute | Writes per minute |
|---|---|---|
| Free | 240 | 60 |
| Pro | 480 | 120 |
| Enterprise | Custom | Custom |
Those numbers live in PLAN_LIMITS in packages/billing/src/limits.ts, which is also what the
pricing card renders, so the enforced ceiling and the advertised one are one value.
Billing and plans is where you change tier.
The counter is keyed on the identity rather than on the credential it authenticated with, so rotating a credential does not hand you a fresh minute. Two identities in the same project count separately.
GET /api/v1/bulk-read-secrets costs one token per value it actually opens, not one token for the
call. An environment of 40 values spends 40 reads. Addresses that mint on demand are skipped and
cost nothing. Poll it on a timer that your ceiling can pay for.
What a refusal looks like
HTTP/1.1 429 Too Many Requests
retry-after: 17{ "error": "rate_limited" }retry-after is whole seconds until the window resets, and it is never below 1. The same body comes
back from the IP ceiling and from the plan ceiling, because telling a caller which one it hit would
say more about other tenants than it says about them.
One other 429 exists. POST /api/v1/dynamic/{address}/lease answers {"error": "ceiling_reached"}
when an environment trips the abuse ceiling on minting credentials, which is a separate limit from
your plan's reads.
Backing off
Sleep for retry-after seconds and send the request again. If the header is ever missing, wait one
second and double the wait on each further 429, up to a minute.
const res = await fetch(url, { headers: { authorization: `Bearer ${credential}` } });
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after") ?? 1) * 1000;
await new Promise((done) => setTimeout(done, wait));
}Three habits keep you under the ceiling.
- Read once at the start of a job and pass the values to one child process.
penv runalready works this way. - Use
GET /api/v1/bulk-read-secretsinstead of a request per key, and remember it is priced per value. - Give each pipeline its own machine identity. Sharing one across a fleet shares one bucket.
An overdraft does not carry. A bulk read that overshoots leaves the bucket over its ceiling for the rest of that minute, and the next minute starts at zero.
Next: versioning.
Authentication
How a machine trades a proof it already has for a short-lived pck_ credential, what that credential resolves to, and how to throw it away.
Versioning
What the /api/v1 prefix promises, which changes we will not make inside it, and where the OpenAPI document that generates these pages is served.