Errors and rate limits
The error envelope
Section titled “The error envelope”Error responses are JSON with an error message. Coded refusals also carry a stable code. Most also carry a requestId:
{ "error": "An app named 'MyApp' already exists.", "code": "app_name_already_exists", "requestId": "f3b2c1d0-4a5e-6789-abcd-ef0123456789"}When code is present, branch on it; the wording of error is not part of the contract. Validation failures add an errors array with one entry per problem. The request id also travels as the X-Request-Id response header on every response, success or error. Quote it when reporting an issue. The release-upload 429 is plain text, not this envelope; see Rate limits.
An API key whose scopes do not cover the route gets a 403 that names what the route wanted:
{ "error": "Insufficient permissions", "code": "insufficient_scopes", "required_scopes": ["deploy"], "requestId": "f3b2c1d0-4a5e-6789-abcd-ef0123456789"}required_scopes lists what the route asked for, not what your key holds, and it only ever names read, deploy, or apps. Access keys and login sessions carry every scope, so they never see this. See Access keys and API keys.
A 403 without required_scopes is a different refusal: your role on the app does not grant the action, or the request never reached the route at all. Branch on whether the field is present rather than on the message text.
One more 403 without a code: while an account is scheduled for deletion, every authenticated request answers 403 with a plain error except GET /v1/account, DELETE /v1/account/deletion, DELETE /v1/sessions/current and GET /v1/authenticated. Read deletionScheduledFor from GET /v1/account to tell this state apart. See Your account.
Asking to delete an account that still owns something answers 409 with a code and a list:
{ "error": "The account cannot be deleted yet. Delete the apps it owns and cancel its paid plan first, then try again.", "code": "account_deletion_blocked", "reasons": ["apps"], "requestId": "f3b2c1d0-4a5e-6789-abcd-ef0123456789"}reasons holds one or more of apps, subscription, and pending_transfer. Branch on code, then on the array. pending_transfer means an outbound cross-workspace invite is still open; cancel it or wait for accept or expiry. See Your account and Apps and collaborators.
Rate limits
Section titled “Rate limits”Three layers apply:
- A global limit of 100 requests per minute.
- A per-tenant limit on management API requests, set by your plan: 60, 300, or 1000 per minute. See Plans, quotas, and MAD.
- Endpoint-specific limits on sensitive routes: registration, login, password reset, and resending a verification email are limited per client address, and release uploads to 100 per 15 minutes. Changing the password and requesting account deletion share two failure budgets: ten per hour per account and client address, and twenty per hour per account across all addresses. Both sit behind the session check and count only what the route answers once your session has been accepted, so a rejected new password, a malformed request, a refused deletion and a step-up round trip cost the same as a wrong password. A call refused for not being a browser sign-in session never reaches them and spends nothing.
Everything counted per client address uses your exact address on IPv4. On IPv6 it uses your /64 prefix, so moving between addresses inside the block you were assigned lands in the same counter.
A rate-limited request gets HTTP 429. The body and headers depend on which layer you hit.
On registration, login, password reset, and resending a verification email, the JSON body includes retryAfterSeconds:
{ "error": "Too many login attempts. Please try again later.", "retryAfterSeconds": 900}Wait that many seconds before retrying. The error string is different on each of those routes. Changing the password and requesting account deletion answer 429 with error alone in the body; Retry-After and the RateLimit-* headers still say how long to wait. The message names which budget ran out, so a caller that hit the per-account one can tell it apart from the per-address one. On a counted failure that is not a 429, RateLimit-* names the budget that will refuse first (the one with fewer remaining). That is the per-address ten until the per-account twenty is closer to empty. A successful change spends nothing; Remaining on that 200 is not the next request’s budget.
If the failed attempts are not yours, someone else is signed in with one of your sessions. Sign out all sessions, under Account → Security, ends them and asks for no password.
Release uploads are limited to 100 per 15 minutes per client address. That 429 is the plain text Too many requests, please try again later., with headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. There is no retryAfterSeconds field.
The per-tenant management limit returns the same JSON envelope as the auth-route 429s:
{ "error": "Rate limit exceeded", "retryAfterSeconds": 60}and sets Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. retryAfterSeconds matches Retry-After. Wait that many seconds before retrying.
The global limit of 100 requests per minute returns { "error": "Too many requests" } with RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy, and Retry-After.
In CI, a 429 on release upload usually means a runaway retry loop rather than real traffic. Wait as long as Retry-After says.
Repeated failed logins ask for a challenge
Section titled “Repeated failed logins ask for a challenge”Failures against a single account escalate on their own track, separately from the address limits above. After a small number of recent failures, the next attempt has to carry a Cloudflare Turnstile token, and the API answers 403 until it gets one:
{ "error": "Please complete the verification challenge to continue.", "code": "challenge_required"}In the dashboard you do not have to do anything special. The challenge appears under the password field, usually clears on its own, and then you sign in again.
If you call POST /v1/auth/login yourself, branch on the code field rather than the message text, and send the token back as challengeToken in the body. A token works once, so each attempt needs a fresh one.
None of this locks the account. The challenge asks the caller to prove they are a person, so someone else failing logins against your email address cannot keep you out of it.