Didit enforces multiple layers of rate limiting to keep the API stable while still allowing healthy bursts of traffic. Limits are applied per identifier — your x-api-key or, if no API key is sent, your client IP. Each scope keeps an independent counter with a 60-second sliding window.
Identifier and exemptions
The middleware picks a rate-limit identifier in this order in this order:
x-api-key header → key-scoped counter.
X-Forwarded-For first hop → IP-scoped counter.
REMOTE_ADDR → IP-scoped counter.
The following calls bypass rate limiting entirely:
- The internal
DIDIT_WEBAPP_API_KEY (used only by the Didit console).
- Endpoints protected by the
IsValidSession permission (the embedded/SDK session traffic).
GET /system/healthcheck (served by HealthMiddleware before this stack).
- Provider webhooks: Stripe, AML watcher, face verification callbacks, KYC list mutation.
Global limits
Every authenticated request is also bucketed by HTTP method into a generic scope, so any new route inherits a sane default:
| Scope | HTTP method(s) | Limit | Window |
|---|
generic-get | GET | 600 / min | 60 s |
generic-write | POST, PATCH, DELETE | 300 / min | 60 s |
Endpoint-specific limits (next section) apply in addition to the global limit. The first scope to exceed its counter is the one that returns 429.
Endpoint-specific limits
The middleware defines stricter scopes for the highest-impact routes:
| Scope | Endpoint(s) | Limit | Source constant |
|---|
session-v2-create | POST /v2/session/, POST /v3/session/ | 600 / min | SESSION_CREATE_RATE_LIMIT |
session-decision | GET /v1/session/<id>/decision/, GET /v2/session/<id>/decision/ | 100 / min | SESSION_DECISION_RATE_LIMIT |
session-generate-pdf | GET /v1/session/<id>/generate-pdf/, GET /v3/session/<id>/generate-pdf/ | 50 / min | SESSION_PDF_RATE_LIMIT |
session-add-images | POST/PATCH /session/<id>/add-images/ | 10 / min | SESSION_ADD_IMAGES_RATE_LIMIT |
session-update-kyc | POST/PATCH /session/<id>/update-data/ | 10 / min | SESSION_UPDATE_KYC_RATE_LIMIT |
session-update-poa | POST/PATCH /session/<id>/update-poa-data/ | 10 / min | SESSION_UPDATE_POA_RATE_LIMIT |
GET /v3/session/<id>/decision/ is served by SessionV3RetrieveView and is not in the session-decision scope. It is governed only by the generic-get 600/min ceiling. The 100/min decision throttle applies to the v1 and v2 decision paths.
The codebase also defines FREE_SESSION_RATE_LIMIT = 10 and PAID_SESSION_RATE_LIMIT = 600. These constants are not currently applied to POST /v3/session/ — that endpoint uses the single session-v2-create limit of 600/min for every application. Do not plan capacity around a per-workflow free-tier throttle until it is documented as live.
429 response shape
When you exceed any scope you get an HTTP 429 with this body and headers:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1762534870
Retry-After: 37
{
"detail": "Session creation rate limit exceeded. You can make up to 600 requests per minute."
}
| Header | Meaning |
|---|
X-RateLimit-Limit | Max requests permitted in the current window for the scope that tripped. |
X-RateLimit-Remaining | Remaining requests before the window resets. Clamped to 0 once you cross the limit. |
X-RateLimit-Reset | Unix epoch seconds when the counter resets to zero. |
Retry-After | Seconds to wait before the next attempt. Present only on 429 responses, not on every request. |
X-RateLimit-* headers are only emitted on the 429 response — successful responses do not include them today. Use the Retry-After value (or X-RateLimit-Reset - now) as your delay before the next attempt.
Client guidance
- Respect
Retry-After as the floor for your backoff. On top of it, layer exponential backoff with jitter (5s → 10s → 20s → 40s ± random) so two clients that hit 429 at the same time do not synchronise.
- Stop retrying after a small ceiling (e.g. 5 attempts). Surface the error to your caller and alert your team — sustained 429s usually mean a runaway loop, not a transient spike.
- Use one identifier per workload. Sharing an
x-api-key across many backends collapses them into the same counter, which can cause one noisy neighbour to starve the rest. Issue a separate application (and therefore a separate api_key) per environment, brand, or customer — see Programmatic registration.
- Cache decision reads. The
/decision/ endpoints are not designed for tight polling loops; if you need real-time updates, use Webhooks and only call GET /v3/session/<id>/decision/ for back-fill or audit.
- Generate PDFs offline. PDF rendering is CPU-bound (50/min cap). Trigger it from a queue after the session reaches a terminal status, not on every page view.
Sample 429-aware client
async function postSession(body) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch("https://verification.didit.me/v3/session/", {
method: "POST",
headers: {
"x-api-key": process.env.DIDIT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status !== 429) return res;
const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
const jitter = Math.random() * 1000;
await new Promise((r) => setTimeout(r, retryAfter * 1000 + jitter));
}
throw new Error("Didit: too many 429s, giving up");
}
import os
import random
import time
import httpx
def post_session(body: dict) -> httpx.Response:
with httpx.Client(timeout=15) as client:
for _ in range(5):
resp = client.post(
"https://verification.didit.me/v3/session/",
headers={
"x-api-key": os.environ["DIDIT_API_KEY"],
"Content-Type": "application/json",
},
json=body,
)
if resp.status_code != 429:
return resp
wait_s = float(resp.headers.get("Retry-After", "1")) + random.random()
time.sleep(wait_s)
raise RuntimeError("Didit: too many 429s, giving up")
Need more headroom?
If 600 sessions per minute is not enough — for example, a marketing launch or a backfill — email support@didit.me with your application UUID and the expected sustained and burst rates. Overrides are configured per application.