# Bulk background check API: run a CSV file through POST /v1/batch

> Send a roster as JSON or as a text/csv body, get one result per row in input order, write recordId, personRef, matchState and the source link back to a CSV, and chunk a 25,000-row file without a burst.

- **HTML:** https://offendersearch.app/docs/learn/bulk-background-check-csv
- **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

## How a bulk background check works: POST /v1/batch and its three body shapes

A bulk background check is many single searches in one HTTP call. `POST /v1/batch` takes up to 1,000 rows, runs each row through the same engine as `POST /v1/search`, and returns one result per row **in input order**. Each row is billed as one search: at $0.15 a call, $0.11 past 2,000 calls in a month, with 25 free calls on sign-up and no card, a 1,000-row batch is 1,000 billable searches, not one. The sandbox at `https://sandbox.offendersearch.app` with an `os_test_` key runs the same endpoint for free, at 60 calls a minute and 2,000 a day per key. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration.

Three body shapes are accepted, and they produce the same result:

- **A JSON array of Query objects**: `[{"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}, …]`. Each object takes the same fields as `query` on a single search: `firstName`, `lastName`, `dob`, `age`, `city`, `county`, `state`, `zipcode`, `address`, `q`.
- **A JSON envelope**: `{"queries": [...], "match": "balanced", "jurisdictions": ["OH"], "recordTypes": [...], "locationScoped": false, "include": ["stateData"]}`. The keys beside `queries` apply to every row, which is where a batch-wide `match` mode or a jurisdiction scope goes.
- **A `text/csv` body** whose header row names Query fields: `firstName,lastName,state,dob,city,zipcode,address,age,q`. One person per line. This is the shape for a file you were handed, and the one the curl tab below sends.

Validation is strict so that a billed batch is a correct one. A JSON row that cannot be read (an unknown key, or a value of the wrong type such as `"dob": "1980"`) makes the whole request a `422` whose `detail` names the row index and the field, and **nothing runs**. A row that fails while it runs is reported in place with `status: "error"` and never aborts the batch. More than 1,000 rows is a `413`. Batch is cache-only: live re-verification is not offered per row, so a row you need to cite or re-verify is a single `POST /v1/search`. The [batch reference](/docs/batch) has the full contract; the [bulk sex offender search](/bulk-sex-offender-search) page describes the product.

> **Per-row clearances.** A JSON row or a CSV column may carry `subjectId`, and optionally `clientId` and `clearanceMode`: each row is then one subject being screened, a person already cleared as `not_this_person` leaves that row's `records[]` for its `cleared[]`, and `counts.clearedSuppressed` says how many. A row value wins over the same key at the envelope level.

## Background check from a CSV file: the request

The sample file uses the sandbox magic names so you can see every outcome in one call: "Clear Visitor" returns zero records; "Exact Match" with 1980-01-01 returns one record identified by date of birth; "Name Only" returns three possible matches in three states with no date of birth on any; "Alias Match" returns one record matched through an alias. A row with no date of birth runs as a name-only search.

*people.csv*
```plain
firstName,lastName,dob
Clear,Visitor,
Exact,Match,1980-01-01
Name,Only,
Alias,Match,
```

The curl tab posts the file as `text/csv`. The Python and Node tabs read the file themselves, keep only the columns the API knows, and send the JSON envelope, which is the shape to use once you add `subjectId` or a batch-wide `match`; both write a results CSV and both chunk at 1,000 rows.

**curl**
```bash
# A bulk background check from a CSV file, on the sandbox (free).
# The header row names Query fields; one person per line; up to 1,000 rows per call.
curl -X POST https://sandbox.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @people.csv
```

**Python**
```python
# pip install requests
import csv, os, sys, time, requests

KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]      # os_test_… from the dashboard
HOST = "https://sandbox.offendersearch.app"
FIELDS = ["firstName", "lastName", "state", "dob", "city", "zipcode", "address", "age", "q"]   # the CSV header the API reads

def run_batch(rows, match="balanced"):
    """rows: list of dicts with Query fields (and optionally subjectId). Up to 1,000 per call."""
    body = {"queries": rows, "match": match}        # the envelope form: batch-wide options apply to every row
    for attempt in range(5):
        r = requests.post(f"{HOST}/v1/batch", headers={"X-API-Key": KEY}, json=body, timeout=300)
        if r.status_code == 429:                     # at capacity: nothing ran, nothing billed
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        if r.status_code == 422:                     # a malformed row: detail names the row index; nothing ran
            raise ValueError(r.json()["detail"])
        r.raise_for_status()
        return r.json()["results"]                   # in input order, one entry per row
    raise RuntimeError("batch did not run after 5 attempts")

with open(sys.argv[1], newline="") as f:
    people = [{k: v for k, v in row.items() if k in FIELDS and v} for row in csv.DictReader(f)]

with open("results.csv", "w", newline="") as out:
    w = csv.writer(out)
    w.writerow(["index", "firstName", "lastName", "dob", "status", "records", "sourcesIncomplete",
                "recordId", "personRef", "matchState", "matchConfidence", "recordUrl", "error"])
    for start in range(0, len(people), 1000):                     # 25,000 rows → 25 calls
        chunk = people[start:start + 1000]
        for row in run_batch(chunk):
            person = chunk[row["index"]]
            base = [start + row["index"], person.get("firstName", ""), person.get("lastName", ""), person.get("dob", "")]
            if row["status"] == "error":
                w.writerow(base + ["error", 0, 0, "", "", "", "", "", row.get("error", "")]); continue
            c = row["counts"]
            if not row["records"]:
                w.writerow(base + [row["status"], c["records"], c["sourcesIncomplete"], "", "", "", "", "", ""]); continue
            for rec in row["records"]:                               # one line per matched record
                w.writerow(base + [row["status"], c["records"], c["sourcesIncomplete"], rec["recordId"], rec["personRef"],
                                   rec["matchState"], rec["matchConfidence"], rec["sources"][0]["recordUrl"], ""])
```

**Node**
```javascript
// Node 18+: fetch is built in; the CSV is split by hand (quote-free input)
import { readFileSync, writeFileSync } from "node:fs";

const KEY = process.env.OFFENDERSEARCH_TEST_KEY;   // os_test_… from the dashboard
const HOST = "https://sandbox.offendersearch.app";
const FIELDS = new Set(["firstName", "lastName", "state", "dob", "city", "zipcode", "address", "age", "q"]);

function parseCsv(text) {
  const [head, ...lines] = text.trim().split(/\r?\n/);
  const cols = head.split(",");
  return lines.map((line) => {
    const cells = line.split(",");
    const row = {};
    cols.forEach((c, i) => { if (FIELDS.has(c) && cells[i]) row[c] = cells[i]; });
    return row;
  });
}

async function runBatch(rows, match = "balanced") {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${HOST}/v1/batch`, {
      method: "POST",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ queries: rows, match }),            // envelope form; up to 1,000 rows
    });
    if (res.status === 429) {                                      // nothing ran, nothing billed
      await new Promise((r) => setTimeout(r, 1000 * Number(res.headers.get("Retry-After") ?? 5)));
      continue;
    }
    if (res.status === 422) throw new Error(JSON.stringify((await res.json()).detail));   // names the row index
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    return (await res.json()).results;                             // in input order
  }
  throw new Error("batch did not run after 5 attempts");
}

const people = parseCsv(readFileSync(process.argv[2], "utf8"));
const out = [["index", "firstName", "lastName", "dob", "status", "records", "sourcesIncomplete",
              "recordId", "personRef", "matchState", "matchConfidence", "recordUrl", "error"].join(",")];

const POOL = 2;                                                    // two batches in flight, never a burst
const chunks = [];
for (let i = 0; i < people.length; i += 1000) chunks.push({ start: i, rows: people.slice(i, i + 1000) });
let next = 0;
await Promise.all(Array.from({ length: POOL }, async () => {
  while (next < chunks.length) {
    const { start, rows } = chunks[next++];
    for (const row of await runBatch(rows)) {
      const p = rows[row.index];
      const base = [start + row.index, p.firstName ?? "", p.lastName ?? "", p.dob ?? ""];
      if (row.status === "error") { out.push([...base, "error", 0, 0, "", "", "", "", "", row.error ?? ""].join(",")); continue; }
      const c = row.counts;
      if (!row.records.length) { out.push([...base, row.status, c.records, c.sourcesIncomplete, "", "", "", "", "", ""].join(",")); continue; }
      for (const rec of row.records)
        out.push([...base, row.status, c.records, c.sourcesIncomplete, rec.recordId, rec.personRef,
                  rec.matchState, rec.matchConfidence, rec.sources[0].recordUrl, ""].join(","));
    }
  }
}));
writeFileSync("results.csv", out.join("\n"));
```

Both programs call `https://sandbox.offendersearch.app` with the key from `OFFENDERSEARCH_TEST_KEY`; a production run swaps the host for `https://api.offendersearch.app` and the key for an `os_live_` one. On production, a trial account cannot run a batch (`402`): add billing first. The [background check API pricing](/background-check-api-pricing) page has the graduated rate the cost section below applies.

## Reading each bulk result row: status, counts and matchState

The response is `{ "count", "results": [...] }`. Each entry in `results[]` carries `index` (the 0-based input row), `status` ("complete", "partial" or "error"), `counts`, `warnings[]` and `records[]`; a row that sent `subjectId` also carries `cleared[]`. A batch row carries no `searchId`, no `sourceStatus[]`, no `page` keys and no `proof`, so `counts.sourcesIncomplete` and `warnings[]` are the only completeness signals a row has, and you must read them **per row**: one row can be short while the rest are whole.

*The four sample rows (abridged)*
```json
// 200 from POST /v1/batch for the four sample rows (abridged). results[] is in input order.
{
  "count": 4,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 0 },
      "warnings": [], "records": [] },
    { "index": 1, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 1 },
      "warnings": [],
      "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "recordType": "sex_offender",
                     "matchState": "dob_match", "unverified": false, "matchConfidence": 0.9,
                     "name": { "full": "Exact Sample Match" }, "dob": "1980-01-01", "registrationState": "OH",
                     "sources": [ { "jurisdiction": "OH", "recordUrl": "https://offendersearch.app/docs/sandbox", "lastCheckedAt": "2026-10-01T00:00:00+00:00" } ] } ] },
    { "index": 2, "status": "complete",
      "counts": { "records": 3, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 3 },
      "warnings": [],
      "records": [ /* three records in three states, matchState "no_dob_age_year", unverified true */ ] },
    { "index": 3, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 1 },
      "warnings": [],
      "records": [ /* one record matched through an alias: matchedName.type "alias", matchBasis includes "alias:exact" */ ] }
  ]
}

// A row that failed while it ran is reported in place and never aborts the batch:
{ "index": 7, "status": "error", "error": "…", "counts": { "records": 0, "sourcesQueried": 0, "sourcesIncomplete": 0, "sourcesComplete": 0, "sourcesSkippedByScope": 0, "recordsReturned": 0 }, "warnings": [], "records": [] }
```

| Read | Then |
| --- | --- |
| `status` is `"error"` | The row did not run; `error` says why. Fix the input and resend that row alone. The other rows ran and were billed. |
| `counts.sourcesIncomplete` > 0, or `status` is `"partial"` | The row is a lower bound. A zero here is not a clear; queue the row for a single `POST /v1/search` later. |
| `counts.records` is 0 and the row is complete | No registrant matched the row across `counts.sourcesQueried` registries. Say "0 of 58 matched", not "clean". |
| A record with `matchState` `dob_match` and `unverified` false | An identification: the full date of birth agrees. Act on it. |
| A record with `matchState` `no_dob_age_year`, or `null` when the row sent no `dob` | A name match only. Route it to a reviewer with the registry link; never auto-flag. |
| `matchBasis` contains `alias:exact`, `matchedName.type` is `alias` | The person was found under another name they are registered with. Show the matched alias beside the legal name. |

The [background check API response explained](/docs/learn/background-check-api-response-explained) tutorial walks every key on a single-search record; batch rows carry the same records.

## Writing bulk background check results back to a CSV

A results file is what the person who gave you the roster can act on, so write one line per matched record, with the input row beside it. The columns that matter: your `index` (or your own id, joined through it), the input name and date of birth, the row's `status` and `counts.records`, `counts.sourcesIncomplete`, and then per record `recordId`, `personRef`, `matchState`, `matchConfidence` and `sources[].recordUrl`. The code above writes exactly that. A row with no match gets one line with the record columns empty, and a row with `status: "error"` gets one line with the `error` text, so the output always has at least as many lines as the input and nobody is silently missing.

`recordId` is what `GET /v1/records/{recordId}` fetches when a reviewer opens a line; `personRef` is the stable reference to the person behind the record, which is what you store if you will screen the same roster again; `sources[].recordUrl` is the official registry page a reviewer confirms against before anything is acted on.

## Chunking a 25,000-row roster: 25 bulk calls, not a burst

A 25,000-row roster is 25 calls of 1,000 rows. The temptation is to fire all 25 at once; do not. Each batch already runs its rows with bounded concurrency on the server, and 25 of them in parallel is the shape that earns a `429` (`detail.code` "overloaded", with a `Retry-After` header of 1 to 120 seconds), at which point nothing in that call was searched or billed and you have to resend it anyway. Run the chunks through a pool of **two or three** workers, as the Node example does with `POOL = 2`, honour `Retry-After` on a `429`, retry a `502`, `503` or `504` with backoff, and never retry a `422`: it is the same malformed row every time. The full retry table is in [rate limits, retries and idempotency](/docs/learn/rate-limits-retries-idempotency).

Idempotency is the other reason to keep the pool small. `POST /v1/batch` has no idempotency header: a batch you resend because a timeout ate the response is a second billed batch. Set a generous client timeout (the Python example uses 300 seconds), persist each chunk's `results[]` as soon as it arrives, and resume from the first chunk you have no results for, not from the start. Overnight is the right time to run a roster this size, when your own traffic is low; a 25,000-row screen is not a job for the middle of a working day.

## Bulk criminal record checks: POST /v1/criminal/batch

The [criminal records API](/criminal-records-api) has the same batch shape at `POST /v1/criminal/batch`: a JSON array of `query` objects, a JSON envelope `{ "queries": [...], ...batch-wide defaults }` where any other key (`include`, `state`, `perPage`…) applies to every row and a per-row value overrides it, or a `text/csv` body whose header row names `query` field names. Up to 1,000 rows, results in input order, each row that ran billed as one call. Two differences from the registry batch: `lastName` is required on every row, and a row that fails validation (a missing `lastName`, say) is returned in place with `status: "error"` and its reason rather than failing the request. Criminal batch is cached only; live verification is not offered per row.

*POST /v1/criminal/batch*
```bash
# The criminal side: same idea, lastName required per row, cached only, one criminal_call per row that ran
curl -X POST https://sandbox.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries": [{"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"},
                   {"firstName": "Clear", "lastName": "Record"},
                   {"firstName": "NoSurname"}],
       "include": ["sourceData"]}'

# 200 — NOT the standard envelope: a wrapper with one entry per input row, in input order
{ "count": 3,
  "results": [
    { "index": 0, "status": "complete", "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false },
      "warnings": [], "page": 1, "perPage": 50, "totalPages": 1, "sourceStatus": [], "omittedFields": [],
      "records": [ { "recordType": "court_case", "matchState": "dob_match", "cases": [ { "charges": [ { "description": "Burglary", "severity": "felony", "disposition": "Guilty" } ] } ] } ] },
    { "index": 1, "status": "complete", "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false }, "warnings": [], "records": [] },
    { "index": 2, "status": "error", "error": "a lastName predicate is required — an unbounded query returns a lottery, not an answer",
      "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false }, "records": [] }
  ] }

# A CSV body works here too: a header row of query field names (firstName,lastName,dob,state,county …);
# empty cells are dropped, a UTF-8 BOM is handled, unknown columns are ignored.
# 413 { "error": { "code": "batch_too_large" } } above 1,000 rows; 422 bad_batch / empty_batch for an unreadable or empty body.
```

A criminal row is the criminal search envelope without the per-request keys: `status`, `counts` (`records`, `recordsReturned`, `sourcesQueried`, `sourcesComplete`, `sourcesIncomplete`, `candidatesCapped`, `exclusionsIncluded`), `warnings[]`, `page`, `perPage`, `totalPages`, `sourceStatus[]`, `omittedFields[]` and `records[]`. Read each record's `recordType` and `matchState`, then `cases[].charges[].disposition`, because a `jail_booking` with an empty disposition is an arrest and not a conviction; the [search criminal records by name and date of birth](/docs/learn/search-criminal-records-by-name-and-dob) tutorial reads one record in full. The [criminal batch reference](/docs/criminal/batch) and the [bulk criminal record search](/bulk-criminal-record-search) page cover the rest.

## Bulk background check cost, and the re-screen cadence with monitors

Cost is arithmetic on the per-search rate, because a batch is billed per row. With $0.15 a call for the first 2,000 calls in a month and $0.11 for every call past 2,000:

| Roster | Calls | Arithmetic | Month total |
| --- | --- | --- | --- |
| 1,000 people, once | 1,000 | 1,000 × $0.15 | $150 |
| 2,000 people, once | 2,000 | 2,000 × $0.15 | $300 |
| 25,000 people, once | 25,000 | 2,000 × $0.15 + 23,000 × $0.11 | $300 + $2,530 = $2,830 |
| 25,000 people, registry and criminal | 50,000 | 2,000 × $0.15 + 48,000 × $0.11 | $300 + $5,280 = $5,580 |

The number that changes the economics is how often you re-run it. Re-screening a 25,000-row roster monthly is 25,000 billed searches a month to find the handful of people whose status changed. A **person monitor** inverts that: `POST /v1/monitors` with `product: "sex-offender"` (or `"criminal"`), `type: "person"`, the person and a `channels.webhookUrl` creates a standing watch that is evaluated continuously and sends a `monitor.alert` (`event` "new", "changed" or "removed", with `detail.personRef` and `detail.recordId`) only when something changes. $3 a month per person monitor, $10 per location monitor. Run the batch once to establish the baseline, then put monitors on the subjects you keep screening; the [monitor a person with webhooks](/docs/learn/monitor-a-person-with-webhooks) tutorial covers the alert and the signature.

*A monitor per subject instead of a monthly re-run*
```bash
# Re-screen cadence without re-running the batch: one person monitor per subject you keep screening
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" -H "Content-Type: application/json" \
  -d '{"product": "sex-offender", "type": "person", "label": "roster: row 1",
       "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "minConfidence": "dob_match",
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'
# 201 … "channels": {"webhookSecret": "whsec_…"} shown once. On the sandbox this monitor fires one "new" alert at once.
```

## Bulk background check from a CSV file, step by step

1. **Prepare the CSV with the fields the API reads.** A header row of Query field names (`firstName,lastName,state,dob,city,zipcode,address,age,q`), one person per line, `dob` as YYYY-MM-DD. Keep your own id in a column you join on afterwards.
2. **Mint a test key and run the sample file on the sandbox.** Create an `os_test_` key in the dashboard, export it as `OFFENDERSEARCH_TEST_KEY`, and POST the four sample rows (Clear Visitor; Exact Match 1980-01-01; Name Only; Alias Match) as `text/csv` to `https://sandbox.offendersearch.app/v1/batch`. Nothing is billed.
3. **Read results[] in input order.** Each entry carries `index`, `status` (complete, partial or error), `counts` and `records[]`. Join on `index` to your input. A row with `status` "error" carries `error` in place; the other rows ran.
4. **Read each row's counts and matchState.** `counts.records` is how many people matched; `counts.sourcesIncomplete` above zero makes a zero a lower bound, not a clear. On each record, `dob_match` is an identification; `no_dob_age_year` is a lead for a reviewer.
5. **Write the results back to CSV.** One line per matched record (or one per row with no match) with `recordId`, `personRef`, `matchState`, `matchConfidence` and `sources[].recordUrl`, so a reviewer can open the registry page.
6. **Chunk a large roster into 1,000-row calls.** 25,000 rows is 25 calls. Run them through a pool of two or three workers, honour `Retry-After` on a `429` (nothing ran, nothing was billed) and never fire the chunks in parallel.
7. **Set the re-screen cadence with monitors.** Instead of re-running the batch, create a person monitor per subject you keep screening; it watches continuously and sends a `monitor.alert` when something changes. $3 a month per person monitor.

A batch result is a public-records lookup, not a consumer report. A roster screened for hiring, tenancy, credit or insurance runs as FCRA orders on the [FCRA Partner API](/fcra), one order per subject with consent and a permissible purpose recorded; whether your use needs that is a question for your counsel. The [background check API](/background-check-api) page describes both layers, and [Learn APIs by example](/docs/learn) has the rest of this series.

## Bulk background check API: questions developers ask

### Can I run a background check from a CSV file directly?

Yes. `POST /v1/batch` accepts a `text/csv` body whose header row names Query fields (`firstName,lastName,state,dob,city,zipcode,address,age,q`), one person per line, up to 1,000 rows. The results come back as JSON in input order.

### How many rows can one bulk background check hold?

1,000. More is a `413`. A larger roster is several calls, run through a small worker pool; a 25,000-row file is 25 calls.

### How is a bulk background check billed?

Per row, not per request: each row is one search at $0.15 a call, $0.11 past 2,000 calls in a month. A 1,000-row batch is 1,000 billable searches. On the sandbox with an `os_test_` key nothing is billed.

### What happens when one row is bad?

It depends on when it fails. A JSON row that cannot be read (an unknown key, a wrongly typed value) is a `422` for the whole request, with `detail` naming the row index and the field, and nothing runs. A row that fails while it runs is `status: "error"` in place and the other rows are unaffected. On `POST /v1/criminal/batch` a row that fails validation is also reported in place.

### Are the results in the same order as my file?

Yes, always, and each entry carries `index`, the 0-based input row, so you can join without relying on order.

### Can a batch row be re-verified live or cited in a report?

No. Batch is cache-only and a row carries no `searchId`. For a row you need to cite with `POST /v1/report`, or re-verify with a `live` block, run that person as a single `POST /v1/search`.

### Should I re-run the batch every month?

Usually not. Run it once for the baseline and put a person monitor on each subject you keep screening; the monitor is evaluated continuously and sends an alert only when something changes, at $3 a month per person monitor.

## What this page does not cover

- Async single searches and their `webhookUrl` (`POST /v1/searches`): the [async and webhooks](/docs/async-and-webhooks) reference.
- The record keys beyond the match and citation fields: [background check API response explained](/docs/learn/background-check-api-response-explained).
- Verifying the monitor webhook signature and handling retries: the [webhook example](/docs/learn/webhook-example).
- Screening a roster for hiring or tenancy as FCRA orders: the FCRA orders reference under /docs/fcra.

## Related

- [Bulk sex offender search](https://offendersearch.app/bulk-sex-offender-search)
- [Bulk criminal record search](https://offendersearch.app/bulk-criminal-record-search)
- [Batch reference (sex offender)](https://offendersearch.app/docs/batch)
- [Batch reference (criminal)](https://offendersearch.app/docs/criminal/batch)
- [Background check API pricing](https://offendersearch.app/background-check-api-pricing)
- [Background check API](https://offendersearch.app/background-check-api)
- [Criminal records API](https://offendersearch.app/criminal-records-api)
- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Background checks for marketplaces](https://offendersearch.app/docs/learn/background-checks-for-marketplaces)
- [Rate limits, retries and idempotency](https://offendersearch.app/docs/learn/rate-limits-retries-idempotency)
- [Monitor a person with webhooks](https://offendersearch.app/docs/learn/monitor-a-person-with-webhooks)
- [Background check API for HR and ATS](https://offendersearch.app/docs/learn/background-check-api-for-hr-ats)
