Rate limits
There are two independent limits, and they are enforced in this order:
- Per client IP — 100 requests / 60 s, checked before your API key is even read.
- Per tenant, per endpoint — checked after authentication.
Exceeding either one returns 429 with the error code rate_limited. Most integrators only ever read the second table and then cannot explain their 429s, so start with the first.
Layer 1 — per IP
| Scope | Limit | Window |
|---|---|---|
| One client IP, across every rate-limited route | 100 requests | 60 s |
This applies to every endpoint that accepts traffic from a user's device:
POST /v1/verify/initPUT /v1/verify/upload/:verificationId/:roleGET /v1/verify/challenge/:verificationId/nextPOST /v1/verify/submitGET /v1/verify/:id
It does not apply to GET /health or to OPTIONS preflight requests.
The IP is taken from Cloudflare's cf-connecting-ip header — the real client address, not any proxy you put in front of your own app.
Why this one runs before auth
Authenticating costs a KV read. If an unauthenticated flood had to be authenticated before it could be rejected, the flood would still cost us the lookup. So the IP counter runs first.
The consequence for you is worth stating plainly: a 429 can arrive on a request whose API key was never checked. It is not evidence that your key is valid, and it is not attributable to any tenant. If you are debugging a 429 and your per-tenant numbers look nowhere near the limits below, this is the layer you are hitting.
Layer 2 — per tenant, per endpoint
| Endpoint | Limit | Window |
|---|---|---|
POST /v1/verify/init | 60 requests | 60 s |
POST /v1/verify/submit | 30 requests | 60 s |
GET /v1/verify/:id | 600 requests | 60 s |
Each endpoint has its own counter. Spending your init budget does not touch your status budget.
PUT /v1/verify/upload/... and GET /v1/verify/challenge/.../next have no per-tenant limit at all — they authenticate with a per-verification upload token, not with an API key, so there is no tenant to count against. They are governed by the per-IP limit alone. That is exactly why the per-IP limit matters more than it looks.
The numbers above are fixed in the Worker's route definitions. There is no per-tenant override field, so "we'll raise your limit" is not something support can do without shipping a deploy. If your volume genuinely needs more, say so early — but plan against these numbers, not against a promised exception.
The window is fixed, not sliding
The counter buckets by wall-clock minute (floor(unix_seconds / 60)), not by a rolling 60 seconds from your first request. Two practical consequences:
- The reset is at the top of the minute. If you hit the limit at
12:04:59, you are unblocked one second later, not 60 seconds later. - A burst can straddle the boundary. 100 requests at
12:04:59plus 100 at12:05:00are both allowed — 200 requests in two seconds, all legal. Do not build a load test that concludes the limit is 200; do not build a client that relies on being able to do that.
What a 429 looks like
{
"error": "rate_limited",
"message": "Rate limit exceeded — retry later",
"requestId": "9f511d92ac11236d-SJC",
"detail": {
"retry_after": 60
}
}
The same value is in the Retry-After response header, in seconds. Read the header rather than hardcoding a delay:
if (response.status === 429) {
const waitSec = Number(response.headers.get('Retry-After') ?? 60);
await new Promise(r => setTimeout(r, waitSec * 1000));
// then retry — see the note on which layer you hit, below
}
rate_limited is one of the few Veridia errors that is genuinely worth retrying. The others are backend_unavailable (503) and internal_error (500). Everything else is a client error and retrying it just reproduces it. See Errors.
The CGNAT problem — read this before you ship to mobile
This is the failure mode we see most, and it is not obvious from the tables above.
One verification is not one request. Count what a single user actually spends from the per-IP budget:
| Flow | Requests from the user's device |
|---|---|
| Standard (document front + selfie) | ~4: init, 2 uploads, submit |
| Standard with document back | ~5 |
With activeLiveness: true | ~26: init, 3 document/selfie uploads, 1 liveness anchor upload, 16 liveness frame uploads (4 steps x 4 frames), ~4 challenge /next calls, submit |
The active-liveness number is the one that bites. The challenge is 4 pose steps with a 4-frame burst each, and every frame is its own PUT. That is by design — the frames are what the anti-injection check is built on — but it means one liveness verification costs roughly a quarter of the per-IP budget.
Now put several users behind one egress IP. That is the normal case in Latin America: carrier-grade NAT (CGNAT) puts thousands of mobile subscribers behind a handful of shared public addresses. Corporate offices, university networks, and public Wi-Fi do the same thing.
The arithmetic:
- Standard flow: about 20 concurrent users per minute per shared IP before anyone sees a 429.
- Active liveness: about 3 concurrent users per minute per shared IP.
When it happens, it fails in the middle of capture — the user has already photographed their document and is being told something went wrong. And because it is the per-IP layer, it will happen to users who share an address with someone else's session, which makes it look random.
We would rather you knew this than discovered it. It is a real ceiling in the current design, not a tuning knob we forgot to turn up.
What to do about it
- Don't retry uploads aggressively. A client that retries a failed
PUTthree times turns one user's 429 into four, and pushes the shared IP further over the limit. Retry once, with theRetry-Afterdelay, then surface the error. - Handle
rate_limitedin the widget's error event and show a "network is busy, try again in a moment" message rather than a generic failure. The user's next attempt will very likely succeed, because the window resets at the top of the minute. - Enable
activeLivenesswhere it earns its cost, not everywhere. It is the strongest anti-injection signal available, and it is also 6x the request volume. High-value onboarding: yes. Low-risk re-verification: probably not. - Poll from your server, not the browser.
GET /v1/verify/:idrequires a secret key anyway, so it is already server-side — which means its 600/minute budget is spent from your server's IP, not your users'. Keep it that way. - Tell us your traffic shape before launch if you expect concentrated mobile volume. We cannot raise the limit per tenant today, but we would rather plan the deploy with you than read about it in an incident.
Which layer did I hit?
The 429 body is identical for both, so use this instead:
| Symptom | Layer |
|---|---|
You are well under 60 init/min for the whole tenant, but individual users fail | Per IP. Several users share an egress address. |
429 on PUT .../upload/... or on the challenge endpoint | Per IP, always — those routes have no tenant limit. |
| 429 arrives even with a revoked or malformed API key | Per IP — the key was never checked. |
Your own server, one IP, calling GET /v1/verify/:id in a tight poll loop | Could be either. 100/min per IP bites long before 600/min per tenant. |
Bulk init from your backend, above 60/min | Per tenant. |
Note the fourth row: if you poll from a single server, the per-IP limit of 100 is the effective ceiling on polling, not the 600 in the tenant table. A 500 ms poll interval is 120 requests/minute and will be throttled. Poll at 1 s or slower, or use webhooks and stop polling.
Staying under the limits
- Prefer webhooks over polling. A webhook is zero requests. A 30-second poll at 1 Hz is 30.
- Back off on the poll interval. Start at ~1 s and grow it; a verdict typically lands in 2-3 seconds, so a fixed tight loop mostly burns budget on the tail.
- Never retry a 4xx other than 429.
invalid_body,verification_not_found,secret_key_requiredandinsufficient_creditswill return the same answer every time, and each retry still counts against both limits. - Do not call
/initspeculatively. Call it when the user actually starts the flow. Aninitper page view is an easy way to spend 60/minute on people who never open the camera.
What's next
- Errors — the full error catalog, including which codes are retryable
- Authentication — key types, and why the results endpoint is server-side only
- Webhooks — the way to stop polling entirely