Reference
Rate limits
Per-tenant /v3 buckets, 429 responses, and how to back off.
The ThirdFactor Verify API (/v3) is rate-limited per tenant. Over-limit requests
receive HTTP 429; back off and retry. Rate limiting exists to keep one
tenant's traffic spike — a bulk import, a runaway retry loop, a load test
pointed at the wrong environment — from degrading the service for everyone
else on the platform.
Why this matters when you integrate
If you've only worked against APIs with a single global limit, /v3's bucket
model changes how you should design retries and monitoring:
- A burst of
POST /v3/session/calls during a marketing push won't throttle your webhook-driven reconciliation reads, because they're different buckets. - A batch job hammering
GET /v3/session/to poll status won't block your ability to create new sessions for other users. - Each call site in your integration should own its own backoff/retry logic sized to the bucket it hits, rather than sharing one global rate limiter.
The buckets
/v3 traffic is split into independent buckets so heavy work in one area does
not starve another:
| Bucket | Covers |
|---|---|
| Reads | GET endpoints — listing and reading sessions, decisions, logs, checklists, workflows, usage, credits. |
| Session creation | POST /v3/session/. |
| Inference tools | The heavy standalone tools — AML check, face search, signature verify/search, document extract. |
Because the buckets are independent, a throttle on session creation does not throttle your reads, and vice versa. Size your retry logic per call site rather than assuming one global limit.
Note
Session-management writes (update-status, delete, tags) and the PDF
report endpoint are lower-volume, operator-driven calls. If you script them at
scale (e.g. a bulk back-office cleanup), treat them conservatively and add
your own client-side pacing even though they aren't the primary inference
bucket — a script that fires hundreds of update-status calls in a tight
loop is exactly the pattern rate limiting exists to catch.
Handling 429
When you receive 429, back off with exponential delay and jitter, then retry
the same request. Jitter matters as much as the exponential curve: without it,
many clients that got throttled at the same moment retry in lockstep and
re-trigger the limit together.
async function withBackoff<T>(fn: () => Promise<Response>, max = 5): Promise<Response> {
let attempt = 0;
for (;;) {
const res = await fn();
if (res.status !== 429 || attempt >= max) return res;
const wait = Math.min(2 ** attempt * 250, 8000) + Math.random() * 250; // jittered
await new Promise((r) => setTimeout(r, wait));
attempt++;
}
}import random, time
import requests
def with_backoff(make_request, max_attempts=5):
for attempt in range(max_attempts):
res = make_request()
if res.status_code != 429:
return res
wait = min(2 ** attempt * 0.25, 8.0) + random.random() * 0.25
time.sleep(wait)
return resfunc withBackoff(makeRequest func() (*http.Response, error), maxAttempts int) (*http.Response, error) {
for attempt := 0; attempt < maxAttempts; attempt++ {
res, err := makeRequest()
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusTooManyRequests {
return res, nil
}
res.Body.Close()
wait := time.Duration(math.Min(math.Pow(2, float64(attempt))*250, 8000)) * time.Millisecond
wait += time.Duration(rand.Intn(250)) * time.Millisecond
time.Sleep(wait)
}
return makeRequest()
}for i in 1 2 3 4 5; do
status=$(curl -s -o /tmp/resp.json -w "%{http_code}" \
-X GET "$BASE/v3/session/" -H "x-api-key: $API_KEY")
[ "$status" != "429" ] && break
sleep $(( (2 ** i) < 8 ? (2 ** i) : 8 ))
done
cat /tmp/resp.jsonStaying under the limit
Tip
Prefer webhooks over polling. Listening for identity.kyc.session.* events costs
zero reads; a poll loop across many sessions burns the read bucket and earns
429s. Poll only to reconcile the occasional missed webhook, on a schedule.
- Batch reads.
GET /v3/session/returns a paginated array with?page=/?page_size=(max200, default50); totals ride in theX-Total-Count,X-Page, andX-Page-Sizeresponse headers. Page through it instead of fetching sessions one at a time. - Cache decisions. Once a session is terminal (
approved,declined,expired,abandoned,kyc_expired), its decision won't change without an operator action — store it instead of re-reading. - Reconcile, don't poll. Sweep only sessions still pending after N minutes, as in the verify-a-user guide.
- Filter server-side. Use
?status=in_review(or another filter) to pull only the sessions you actually need to act on, instead of listing everything and filtering client-side. - Batch list operations. For risk lists, prefer the bulk add / replace
endpoints over one
POSTper entry when syncing a nightly export.
Idempotency and retries
Retrying is safe when your request is idempotent:
POST /v3/session/— send anIdempotency-Keyheader. A retry with the same key returns the original session and is never charged twice.POST /api/v1/sessions/— the partner JWT'sjtiis the idempotency key.
That means a 429-triggered retry of a create never double-charges credits as
long as you keep the key stable across attempts.
Warning
The idempotency key must be stable across retries of the same logical
request. Generating a fresh key on every retry attempt (for example, from
Date.now() or a random UUID per call) defeats the protection entirely — each
retry looks like a brand-new session to the server and creates a new one,
debiting credits each time. Derive the key from something that doesn't change
across attempts, like a queue message ID or user_id + intent.
Concurrent requests
Firing many requests for different users at once (e.g. a batch import minting one session per row) is normal and expected — the bucket limit is about rate, not concurrency. If you're importing at scale:
- Pace the loop (a fixed delay or a concurrency limiter) rather than firing every request simultaneously, so a transient burst doesn't trip the session-creation bucket for the whole batch.
- Give every row a stable
Idempotency-Keyderived from its source ID, so a crashed import can safely resume from where it left off without double-creating or double-charging already-processed rows. - Watch
credits_remainingin each create response (or pollGET /v3/credits/ledger/) so a large batch doesn't run the tenant dry mid-import and start returning402partway through.
Monitoring
Treat repeated 429s as an operational signal, not just a per-request retry
target:
- Log the bucket (infer it from the endpoint) alongside each
429so you can tell whether it's your read loop or your create path that's hot. - Alert if your backoff is regularly exhausting its
max_attempts— that usually means sustained overuse rather than a one-off burst, and the fix is to reduce call volume (batch, cache, reconcile) rather than to retry harder.