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:

BucketCovers
ReadsGET endpoints — listing and reading sessions, decisions, logs, checklists, workflows, usage, credits.
Session creationPOST /v3/session/.
Inference toolsThe 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 res
func 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.json

Staying 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= (max 200, default 50); totals ride in the X-Total-Count, X-Page, and X-Page-Size response 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 POST per entry when syncing a nightly export.

Idempotency and retries

Retrying is safe when your request is idempotent:

  • POST /v3/session/ — send an Idempotency-Key header. A retry with the same key returns the original session and is never charged twice.
  • POST /api/v1/sessions/ — the partner JWT's jti is 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-Key derived 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_remaining in each create response (or poll GET /v3/credits/ledger/) so a large batch doesn't run the tenant dry mid-import and start returning 402 partway 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 429 so 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.

FAQ