# Batch searches

> One HTTP call, up to a thousand cached searches, one result per row in input order. Each row that runs is billed as a search, and no row can be live.

- **HTML:** https://offendersearch.app/docs/guides/criminal/batch-searches
- **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 you'll build

A roster screen: take a census, an applicant list or a daily visitor log, send it as one batch, and get back a result for every row — the same envelope a single `POST /v1/criminal/search` returns, minus the per-request keys. Rows that fail validation come back in place with the reason and do not stop the others. The batch engine is the cached engine, so a row's answer is identical to running it alone.

## Before you start

- A test key (`os_test_…`) on `https://sandbox.offendersearch.app`. Use the sandbox names: `Felony Record` (`1975-06-15`) returns one record, `Clear Record` zero, and a row without a surname is an `error` row.
- Three input shapes: a JSON array of `query` objects; a JSON envelope `{ "queries": [...], ...defaults }` where any other key (`include`, `nameStrategy`, `state`, `perPage`…) is a default applied to every row; or `text/csv` with a header row of `query` field names (empty cells dropped, a UTF-8 BOM tolerated, unknown columns ignored).
- The reference: [Batch & CSV](/docs/criminal/batch).

> **Rows carry no searchId, and no row can be live.** A batch row is the envelope without `searchId`, `meterEvents`, `usage` and `resolution`. If you need a report or a history entry for a person, run that person through `POST /v1/criminal/search` instead. A `live` key in a batch is ignored; every row is cache-only, so a row is never `partial`.

## Step by step

1. **Build the rows.** One `query` object per person, each with `lastName` and whatever else you hold (`firstName`, `dob`, `state`). Keep your own row ids outside the request and join on `index` (0-based input position) when results return.
2. **Send up to 1,000 rows.** `POST /v1/criminal/batch`. More than 1,000 is `413 batch_too_large`; zero rows is `422 empty_batch`; an unparseable body or a CSV without a header is `422 bad_batch`.
3. **Read each row by status.** `complete` → an envelope; read `counts.records` and `records[]`. `error` → `error` sentence (and `errorCode: "query_too_broad"` when that was the reason), zeroed `counts`, `records: []`. `not_run` → the row could not be searched within this request; it is listed in `unfinished[]`.
4. **Resubmit unfinished rows.** Collect `unfinished[].query` into a new batch. Nothing was billed for them.

**cURL (JSON)**
```bash
curl -s https://sandbox.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: application/json" \
  -d '{
    "queries": [
      { "lastName": "Record", "firstName": "Felony", "dob": "1975-06-15" },
      { "lastName": "Record", "firstName": "Clear" },
      { "firstName": "Nobody" }
    ],
    "perPage": 50
  }'
```

**cURL (CSV)**
```bash
cat > roster.csv <<'CSV'
firstName,lastName,dob,state
Felony,Record,1975-06-15,OH
Clear,Record,,
CSV

curl -s https://sandbox.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: os_test_your_key" -H "Content-Type: text/csv" \
  --data-binary @roster.csv
```

**Python**
```python
import requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"

def screen_roster(people, chunk=1000):
    """people: list of {"id": ..., "lastName": ..., "firstName": ..., "dob": ...}. Yields (person, row)."""
    for start in range(0, len(people), chunk):
        batch = people[start:start + chunk]
        queries = [{k: v for k, v in p.items() if k != "id" and v} for p in batch]
        r = requests.post(f"{API}/v1/criminal/batch", headers={"X-API-Key": KEY},
                          json={"queries": queries, "perPage": 50}, timeout=330)
        body = r.json()
        if r.status_code != 200:
            raise RuntimeError(f"{r.status_code}: {body.get('error') or body.get('detail')}")
        for row in body["results"]:
            yield batch[row["index"]], row                 # join on index, not on name
        for u in body.get("unfinished", []):               # not_run rows: resubmit, nothing billed
            print("resubmit", batch[u["index"]]["id"])
        billed = sum(e["quantity"] for e in body["meterEvents"] if e["kind"] == "criminal_call")
        print(f"{body['count']} rows, {billed} billed")

people = [
    {"id": "A1", "lastName": "Record", "firstName": "Felony", "dob": "1975-06-15"},
    {"id": "A2", "lastName": "Record", "firstName": "Clear"},
    {"id": "A3", "firstName": "Nobody"},                   # no surname → error row
]
for person, row in screen_roster(people):
    if row["status"] == "complete":
        hits = [x for x in row["records"] if x["matchState"] == "dob_match"]
        print(person["id"], "complete:", row["counts"]["records"], "records,", len(hits), "confirmed")
    elif row["status"] == "error":
        print(person["id"], "error:", row.get("errorCode"), row["error"])
    else:
        print(person["id"], "not run")
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': 'os_test_your_key', 'Content-Type': 'application/json' };

async function screenRoster(people) {
  const out = [];
  for (let start = 0; start < people.length; start += 1000) {
    const batch = people.slice(start, start + 1000);
    const queries = batch.map(({ id, ...q }) => Object.fromEntries(Object.entries(q).filter(([, v]) => v)));
    const r = await fetch(API + '/v1/criminal/batch', { method: 'POST', headers: H, body: JSON.stringify({ queries, perPage: 50 }) });
    const body = await r.json();
    if (r.status !== 200) throw new Error(r.status + ': ' + JSON.stringify(body.error ?? body.detail));
    for (const row of body.results) out.push({ person: batch[row.index], row });   // join on index
    for (const u of body.unfinished ?? []) console.log('resubmit', batch[u.index].id); // nothing billed
    const billed = body.meterEvents.filter((e) => e.kind === 'criminal_call').reduce((n, e) => n + e.quantity, 0);
    console.log(body.count + ' rows, ' + billed + ' billed');
  }
  return out;
}

const people = [
  { id: 'A1', lastName: 'Record', firstName: 'Felony', dob: '1975-06-15' },
  { id: 'A2', lastName: 'Record', firstName: 'Clear' },
  { id: 'A3', firstName: 'Nobody' }, // no surname → error row
];
for (const { person, row } of await screenRoster(people)) {
  if (row.status === 'complete') console.log(person.id, 'complete:', row.counts.records, 'records');
  else if (row.status === 'error') console.log(person.id, 'error:', row.errorCode, row.error);
  else console.log(person.id, 'not run');
}
```

*200 — one result per row, in input order, trimmed*
```json
{
  "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": [], "cappedOmittedSources": [], "omittedFields": ["sourceData"],
      "records": [ { "recordCategory": "court", "matchState": "dob_match", "matchConfidence": 0.99, "…": "…" } ],
      "proof": { "status": "none" },
      "legal": { "notice": "Not a consumer report. …" } },
    { "index": 1, "status": "complete",
      "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false },
      "warnings": [], "page": 1, "perPage": 50, "totalPages": 1,
      "sourceStatus": [], "cappedOmittedSources": [], "omittedFields": ["sourceData"],
      "records": [], "proof": { "status": "none" }, "legal": { "notice": "Not a consumer report. …" } },
    { "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": [] }
  ],
  "meterEvents": [
    { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
    { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null }
  ]
}
```

Two rows ran and two `criminal_call` events were metered — one event per ran row, `quantity: 1` each. The error row cost nothing. A production batch can also end with `"unfinished": [ { "index": 7, "query": { … } } ]` and a row `{ "index": 7, "status": "not_run", "error": "not run: this row could not be searched within this request…", "counts": { … }, "records": [] }`.

## Reading the result

| Field | Values | Read as |
| --- | --- | --- |
| `count` | integer | Always equals the number of input rows. |
| `results[].index` | 0-based integer | The input position. Join on it; results are in input order but the index is the contract. |
| `results[].status` | `complete` · `error` · `not_run` | Ran · failed validation (or was too broad) · could not be searched within this request. Never `partial`: there is no live in batch. |
| `results[].error` | sentence, on `error` and `not_run` | The validation message, or the too-broad sentence, or the not-run notice. |
| `results[].errorCode` | `query_too_broad`, present only on that case | Narrow the row with `firstName` or `state`. Other validation errors carry no code; the sentence names the field. |
| `unfinished[]` | `{index, query}`, present only when rows were not run | What to resubmit. Nothing was billed for these rows. |
| `meterEvents[]` | one `criminal_call` per ran row | Count them to reconcile; `error` and `not_run` rows emit none. |
| row `counts`, `records[]`, `coverage` | as on a search | A `complete` row is the search envelope minus `searchId`, `meterEvents`, `usage`, `resolution`; `sourceStatus` is always `[]`. |

## Edge cases and pitfalls

- **`include` is read from the envelope only.** A per-row `include` is ignored. Put `include: ["coverage"]` or `["exclusions"]` at the top level and it reaches every row.
- **`page`/`perPage` are validated per row.** A row with `perPage: 300` is an `error` row naming `perPage`; the rest still run.
- **A too-broad row does not fail the batch.** It comes back `status: "error"` with `errorCode: "query_too_broad"`. Resubmit it alone with more fields.
- **Long batches are stitched.** The service works through rows in parallel inside its budget; the gateway resubmits what did not finish for up to about five minutes and merges results by index. Only when that budget is spent do `not_run` rows and `unfinished[]` reach you. Set a client timeout of at least 330 seconds.
- **A `live` default or a `live` on a row is ignored**, not refused. If you need live verification for some people, run them individually after the batch.
- **Clearances do not apply to batch rows.** `subjectId` is honoured only on a synchronous `POST /v1/criminal/search`. Apply your own `personRef` suppression to rows.
- **CSV columns are `query` field names.** `firstName,lastName,dob,state,county,city,zipcode,birthYear,age` all work; a blank cell is dropped; a column the API does not know is ignored. Send `Content-Type: text/csv` — this is the one path where the gateway forwards your content type.

## Production checklist

- Chunk at 1,000 rows and keep your own ids outside the request; join on `index`.
- Handle all three row statuses; resubmit `unfinished[]` automatically.
- Reconcile `meterEvents[]` (one `criminal_call` per ran row) against rows with `status: "complete"`.
- Run people who need a `searchId` (for a report or history), a live check, or clearances through `POST /v1/criminal/search`.
- Treat `unverified: true` rows as review items exactly as you would on a single search.

## Related

- [Batch & CSV](https://offendersearch.app/docs/criminal/batch): the reference
- [Search](https://offendersearch.app/docs/criminal/search)
- [Errors & status codes](https://offendersearch.app/docs/criminal/errors)
- [Pagination](https://offendersearch.app/docs/criminal/pagination)
