Developer docs · v1.0.0

API rate limits, retries and idempotency for a screening API

A resilient client retries the right statuses, waits as long as the server asks, never runs a billed search twice by accident, and treats every webhook as a possible duplicate. This page is that client, in Python and Node.

Updated November 3, 2026

What a 429 means on a screening API: nothing was searched or billed

A 429 from the background check API is a capacity signal, not a judgement on your request. It says: the request was well formed, it was not run, nothing was billed, and here is how long to wait. The response carries a Retry-After header in seconds, between 1 and 120, and a detail object with code: "overloaded" and the same number as retryAfterSeconds:

The 429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json

{
  "detail": {
    "code": "overloaded",
    "retryAfterSeconds": 12
  }
}

Because nothing was searched, the only correct reaction is to wait the stated time and send the same request again. Reporting a 429 to a user as a failure is the most common mistake; retrying at once in a tight loop is the second. See the error reference and the API errors explainer.

API rate limits: the sandbox numbers and the production model

The sandbox enforces a fixed limit per test key: 60 calls a minute and 2,000 a day. Cross either and you get the 429 above with a Retry-After: generous for an integration test, tight enough to exercise your retry code before go-live. A test key is free and nothing is billed; the sandbox reference lists the magic names.

Production has no published per-key ceiling. Capacity is shared, and a 429 arrives when the service is at capacity, not when your key crosses a number. So you cannot pre-compute a safe rate; you handle 429 dynamically, as the code below does. And bursts are the thing to avoid: a thousand synchronous searches fired at once are far more likely to meet capacity than the same thousand through a worker pool or one batch. The platform page on errors and retries is the normative statement.

Which statuses to retry, and which API errors never to retry

The retry decision is a property of the status code. A status that says "the server could not do it right now" is retryable; one that says "this request is wrong" produces the same answer every time you send it.

StatusMeaningRetry?
429At capacity. Nothing searched or billed. Retry-After set.Yes, after Retry-After.
502 / 503Upstream or service unavailable.Yes, with backoff.
504Your deadlineMs was hit with onDeadline: "error".Yes, usually with a longer deadline or via async.
400 / 422Validation: a bad field, a query that constrains nothing (QueryTooBroad), a missing lastName on the criminal search (guard_unbounded_query).Never. Fix the request.
401Missing or invalid key: { "detail": "Missing or invalid API key. Send it in the X-API-Key header." }Never. Fix the credential.
402No subscription, or the trial is exhausted.Never. A billing action, not a retry.
Transport failureConnection reset, DNS, TLS, client timeout.Yes, as if it were a 503. Read the idempotency section first.

The criminal search shapes its 422 as a coded object (guard_unbounded_query, invalid_page, ambiguous_location), the registry search as a sentence or a list of { loc, msg, type }. Both are final; keep them out of the retry loop. See the criminal error handling guide and its registry twin.

Retries with exponential backoff and jitter that honour Retry-After

One rule above all others: if the server told you how long to wait, wait exactly that long. Only without a Retry-After (a 502, a transport failure) does your own schedule apply: double a base delay each attempt, add random jitter so clients that failed together do not retry together, and cap both the delay and the attempt count.

# curl ≥ 7.71: --retry honours Retry-After on 429 and 503; --retry-all-errors adds 502/504 and
# transport failures. Never add --retry to a call you cannot afford to run twice (see idempotency).
curl --retry 5 --retry-all-errors --retry-max-time 300 --max-time 130 \
  -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"}, "deadlineMs": 120000}'

Three details matter. The transport-failure branch sets the response to null and falls into the retry path, because a connection reset is indistinguishable from a 503 on the client side. The non-retryable branch raises first, so a 422 never consumes an attempt. The final attempt raises instead of sleeping. Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls above are the integration.

Deadlines: deadlineMs, onDeadline and the 504 retry case

POST /v1/search accepts deadlineMs (the example sends 120000) and onDeadline, which is partial or error. With partial, a search that cannot finish inside the deadline answers 200 with status: "partial", counts.sourcesIncomplete above zero and the finished sources' records; with error, the same situation answers 504. Neither is a validation failure, and both are retryable in the sense that running the search again later may complete.

A screen that shows a reviewer whatever came back wants partial and a visible "some sources did not finish" banner; a batch job that stores a clear-or-not verdict wants error, because a partial stored as clear is wrong. Either way the right second attempt is usually not an identical retry: raise deadlineMs, or move to POST /v1/searches, which has no synchronous deadline. Result completeness defines the fields; the no-match tutorial routes a partial answer.

Client timeout above deadlineMs. Set the HTTP client's timeout a few seconds above deadlineMs (the examples use 130 seconds for a 120-second deadline). A client that aborts at 60 seconds never sees the server's partial or 504 and cannot tell whether the search ran.

The Idempotency-Key on FCRA orders: a 24-hour replay window

The one endpoint with a real idempotency contract is POST /v1/fcra/orders, rightly: an FCRA order is a consumer report with consent and a subject attached, and creating two by accident is worse than running two searches. Send an Idempotency-Key header of up to 200 characters, unique per order attempt. Within 24 hours:

  • A replay with the same key and the same body returns the original order, with the response header Idempotent-Replayed: true. No second order is created.
  • A replay with the same key and a different body is refused with 409 and detail.code idempotency_conflict. The key is bound to the first body it saw.
  • After 24 hours the key is forgotten and the same key creates a new order.
POST /v1/fcra/orders with Idempotency-Key
# Same Idempotency-Key + same body within 24 hours → the original order, Idempotent-Replayed: true
curl -i -X POST https://sandbox.offendersearch.app/v1/fcra/orders \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Idempotency-Key: 8f1c2a7e-order-889" \
  -H "Content-Type: application/json" \
  -d '{"endUserId": "eu_…", "permissiblePurpose": "employment",
       "consent": {"obtainedAt": "2026-11-03T13:55:00Z", "method": "esign", "documentRef": "auth-55120"},
       "subject": {"firstName": "Pat", "lastName": "Clear", "dob": "1980-01-01"},
       "useLocation": {"state": "IL"},
       "searches": [{"type": "sex_offender"}, {"type": "criminal"}],
       "clientReference": "cra-order-889"}'

# First call: 202, order at status "received"
# Replay, same body: 202 again, header  Idempotent-Replayed: true, the same order id
# Replay, different body: 409 { "detail": { "code": "idempotency_conflict", … } }

Store the key with the order row before you send, reuse it on every retry of that row, and generate a new one only for a genuinely new order. The FCRA orders reference and the creating orders guide describe the body. The FCRA endpoints are for approved partners; the sandbox runs the same flow with a test key.

Webhook retries, client timeouts and concurrency limits

Webhook deliveries are at-least-once

Idempotency runs in both directions. The platform retries a delivery until it sees a 2xx, and a 200 lost on the way back looks like no 200, so the same event can arrive twice. Key your handler on the FCRA event id (fev_…) or the monitoring alertId (hit_…): answer 200 if you have seen it, store it and enqueue the work if not. The webhook example is a complete receiver with that step; monitoring delivery gives the alert schedule.

Timeouts

Every call needs a client timeout longer than the longest answer you asked for: deadlineMs plus a margin for a synchronous search, a few seconds for a poll or a GET /v1/records/{recordId}. A timeout that fires is a transport failure: it goes into the retry path, and for a synchronous search it is one more reason to prefer the async submission.

Concurrency

Run outbound calls through a small worker pool, not Promise.all over thousands. A handful of workers, each running the retry loop above, keeps your request rate smooth and gives Retry-After something to slow down instead of a thundering herd. A long queue belongs in POST /v1/batch or async searches with webhookUrl, not in more workers. Measure the pool in the sandbox against its 60-a-minute limit before reasoning about production.

Make a screening API client resilient in six steps

  1. Set a client timeout above the deadline. Send deadlineMs on POST /v1/search and give the HTTP client a timeout a few seconds longer, so you read the server's partial or 504 instead of a client-side abort you cannot interpret.
  2. Retry only 429, 502, 503 and 504. Treat 400, 401, 402 and 422 as final: the request itself is wrong, and sending it again produces the same answer. Transport failures (connection reset, DNS) count as retryable.
  3. Honour Retry-After, then back off with jitter. If the response carries Retry-After (or detail.retryAfterSeconds), wait exactly that long. Otherwise double a base delay each attempt, add random jitter, cap it, and give up after a small number of attempts.
  4. Make resubmission safe before you retry a search. A repeated POST /v1/search is a second billed search. For anything that might be retried, submit through POST /v1/searches, persist the searchId from the 202, and poll resultsUrl; a poll is a GET and costs nothing to repeat.
  5. Send an Idempotency-Key on FCRA orders. Generate one key per order attempt, store it with the order, and send it on POST /v1/fcra/orders. A replay inside 24 hours returns the original order with Idempotent-Replayed: true.
  6. Dedupe webhooks and bound your concurrency. Deliveries are at-least-once, so key your handler on the event id or alertId. Run calls through a small worker pool and move bulk work to POST /v1/batch or async searches instead of bursting.

All six steps are testable on the public API for testing with a free os_test_ key, 25 free calls, no card; its 60-a-minute limit is the easiest way to see a real 429. Production is https://api.offendersearch.app with an os_live_ key at $0.15 a call, $0.11 past 2,000 calls in a month.

Rate limit, retry and idempotency questions developers ask

What is the rate limit on the background check API?

The sandbox enforces 60 calls a minute and 2,000 a day per test key. Production publishes no per-key ceiling; a 429 with Retry-After arrives when the service is at capacity, and the client is expected to wait and retry.

Am I billed for a request that returned 429?

No. A 429 means nothing was searched and nothing was billed. Wait the Retry-After seconds and send the same request again.

Should I retry a 422?

Never. A 422 (and a 400, 401 or 402) says the request or the credential is wrong, and resending it returns the same error. Fix the request; the detail tells you which field or which guard.

Is POST /v1/search idempotent?

No. There is no idempotency header on the search endpoints, and every accepted POST /v1/search is a separate billed search. If a request might be retried after a lost response, use POST /v1/searches, keep the searchId, and poll.

How long is an Idempotency-Key remembered?

24 hours, on POST /v1/fcra/orders only. A same-body replay returns the original order with Idempotent-Replayed: true; a different body on the same key is a 409 idempotency_conflict.

What if my webhook receiver gets the same event twice?

That is expected: deliveries are at-least-once. Deduplicate on the event id or alertId before you do any work, and answer 200 for a duplicate.

What this page does not cover