# 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.

- **HTML:** https://offendersearch.app/docs/learn/rate-limits-retries-idempotency
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

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

A `429` from the [background check API](/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
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](/docs/errors) and the [API errors](/docs/learn/api-errors) explainer.

## API rate limits: the sandbox numbers and the production model

The [sandbox](/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](/docs/sandbox) 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](/docs/platform/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.

| Status | Meaning | Retry? |
| --- | --- | --- |
| `429` | At capacity. Nothing searched or billed. `Retry-After` set. | Yes, after `Retry-After`. |
| `502` / `503` | Upstream or service unavailable. | Yes, with backoff. |
| `504` | Your `deadlineMs` was hit with `onDeadline: "error"`. | Yes, usually with a longer deadline or via async. |
| `400` / `422` | Validation: a bad field, a query that constrains nothing (`QueryTooBroad`), a missing `lastName` on the criminal search (`guard_unbounded_query`). | **Never.** Fix the request. |
| `401` | Missing or invalid key: `{ "detail": "Missing or invalid API key. Send it in the X-API-Key header." }` | **Never.** Fix the credential. |
| `402` | No subscription, or the trial is exhausted. | **Never.** A billing action, not a retry. |
| Transport failure | Connection 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](/docs/guides/criminal/error-handling) and its [registry twin](/docs/guides/sex-offender/error-handling).

## 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**
```bash
# 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}'
```

**Python**
```python
import os, random, time, requests

HOST = "https://sandbox.offendersearch.app"            # api.offendersearch.app with an os_live_ key
KEY = os.environ["OFFENDERSEARCH_API_KEY"]             # os_test_…
RETRYABLE = {429, 502, 503, 504}

def search(body: dict, attempts: int = 5) -> dict:
    delay = 1.0
    for attempt in range(1, attempts + 1):
        try:
            r = requests.post(f"{HOST}/v1/search", json=body,
                              headers={"X-API-Key": KEY}, timeout=130)   # above deadlineMs
        except requests.RequestException:
            r = None                                    # transport failure: retry like a 503
        if r is not None and r.status_code not in RETRYABLE:
            r.raise_for_status()                        # 400/401/402/422: never retried
            return r.json()
        if attempt == attempts:
            raise RuntimeError(f"gave up after {attempts} attempts")
        # Honour Retry-After when the server sent one; otherwise exponential backoff with jitter.
        wait = None
        if r is not None:
            wait = r.headers.get("Retry-After")
        if wait is None:
            wait = delay + random.uniform(0, delay)     # 1–2 s, 2–4 s, 4–8 s, …
            delay = min(delay * 2, 60)
        time.sleep(float(wait))

result = search({"query": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"},
                 "deadlineMs": 120000})
print(result["status"], result["counts"]["records"])
```

**Node**
```javascript
// Node 18+ (global fetch, AbortSignal.timeout)
const HOST = "https://sandbox.offendersearch.app";     // api.offendersearch.app with an os_live_ key
const KEY = process.env.OFFENDERSEARCH_API_KEY;        // os_test_…
const RETRYABLE = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function search(body, attempts = 5) {
  let delay = 1000;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    let res = null;
    try {
      res = await fetch(HOST + "/v1/search", {
        method: "POST",
        headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(130_000),          // above deadlineMs
      });
    } catch (e) {
      res = null;                                      // transport failure: retry like a 503
    }
    if (res && !RETRYABLE.has(res.status)) {
      if (!res.ok) throw new Error("HTTP " + res.status + " " + (await res.text()));   // never retried
      return res.json();
    }
    if (attempt === attempts) throw new Error("gave up after " + attempts + " attempts");
    const retryAfter = res && res.headers.get("Retry-After");
    let waitMs;
    if (retryAfter) {
      waitMs = Number(retryAfter) * 1000;              // the server's number wins
    } else {
      waitMs = delay + Math.random() * delay;          // 1–2 s, 2–4 s, 4–8 s, …
      delay = Math.min(delay * 2, 60_000);
    }
    await sleep(waitMs);
  }
}

const result = await search({ query: { firstName: "Jordan", lastName: "Rivera", dob: "1988-04-12" }, deadlineMs: 120000 });
console.log(result.status, result.counts.records);
```

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](/sdk). 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](/docs/result-completeness) defines the fields; the [no-match tutorial](/docs/learn/no-match-and-multiple-match-results) 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.

## Idempotency on the search endpoints: why a repeated POST is a second billed search

`POST /v1/search` and `POST /v1/criminal/search` carry **no idempotency header**. Every accepted request is one search and one billable call, including the one you sent because the first response was lost. That is exactly the case the transport-failure branch handles, so the loop above can run a search twice. For a few interactive calls that is acceptable; for a job retrying thousands of rows it is not.

The safe pattern separates submitting from reading. `POST /v1/searches` takes the same body and answers `202` at once with a `searchId`, `status: "pending"`, `jurisdictionsQueried`, `estimatedCompletionSec` and a `resultsUrl`. Persist the `searchId` against your job row the moment you have it. From then on everything is a `GET /v1/searches/{searchId}`: a poll is free to repeat, and a lost response costs one more poll, not one more search. Only the submission can still be duplicated, and it is cheap to make reliable: write the job row first, mark it "submitting", and if the process dies before the `202` lands, the row tells you to check before submitting again.

*Submit once, persist, poll*
```bash
# 1. Submit once. The 202 is cheap to keep.
curl -X POST https://sandbox.offendersearch.app/v1/searches \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"}}'
# 202 { "searchId": "srch_…", "status": "pending", "jurisdictionsQueried": 58,
#       "estimatedCompletionSec": …, "resultsUrl": "…" }

# 2. Persist searchId against your own job row BEFORE anything else can fail.

# 3. Poll. A poll is a GET: safe to repeat, never a second search.
curl https://sandbox.offendersearch.app/v1/searches/srch_… -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
# … until "status": "complete" (or "partial" — see result completeness)
```

The optional `webhookUrl` on the same body replaces polling with a notification; see [async and webhooks](/docs/async-and-webhooks). The criminal search has the same shape when a live request needs more than two sources: a `202` with `searchId: "crs_…"` and a `poll` path, on the [criminal async](/docs/criminal/async) page.

### Batch and async instead of bursts

`POST /v1/batch` takes up to 1,000 query objects (a JSON array, `{ "queries": [...] }`, or a `text/csv` body with the header `firstName,lastName,state,dob,city,zipcode,address,age,q`) and returns results in input order, billed per row, with a failed row shown as `status: "error"` in place. One batch call spreads the work server-side instead of a thousand synchronous calls competing for capacity. See the [batch reference](/docs/batch) and [bulk sex offender search](/bulk-sex-offender-search); `POST /v1/criminal/batch` is the twin, on the [criminal batch](/docs/criminal/batch) page.

## 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*
```bash
# 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](/docs/fcra/orders) reference and the [creating orders guide](/docs/guides/fcra/creating-orders) describe the body. The FCRA endpoints are for [approved partners](/fcra-api); 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](/docs/learn/webhook-example) is a complete receiver with that step; [monitoring delivery](/docs/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](/sandbox) 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

- The full status-code catalogue and every `detail` shape: the [errors reference](/docs/errors) and [criminal errors](/docs/criminal/errors).
- Verifying the webhook signature and the sender's own retry schedule: the [webhook example](/docs/learn/webhook-example) and [API signature](/docs/learn/api-signature).
- Reading a `partial` answer once you have it: the [no-match and multiple-match tutorial](/docs/learn/no-match-and-multiple-match-results).
- Designing a load test: do not run one against production; the [sandbox](/docs/sandbox) is the place to measure your pool.

## Related

- [Errors and retries (platform)](https://offendersearch.app/docs/platform/errors-and-retries)
- [Errors reference](https://offendersearch.app/docs/errors)
- [Async searches and webhooks](https://offendersearch.app/docs/async-and-webhooks)
- [Batch searches](https://offendersearch.app/docs/batch)
- [Result completeness](https://offendersearch.app/docs/result-completeness)
- [API errors explained](https://offendersearch.app/docs/learn/api-errors)
- [Webhook example](https://offendersearch.app/docs/learn/webhook-example)
- [No-match and multiple-match results](https://offendersearch.app/docs/learn/no-match-and-multiple-match-results)
- [Test a background check integration](https://offendersearch.app/docs/learn/test-a-background-check-integration)
- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
