# Batch searches

> One HTTP call, many searches, results in input order. Each row is a search like any other — billed like one, and read like one.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/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

## When to use this

A roster upload, an overnight re-screen of a client list, a spreadsheet from a facility: when you hold many people at once and want one request, `POST /v1/batch` runs up to 1,000 queries through the same engine as `POST /v1/search` with bounded server-side concurrency and returns one result per row, in order. You will build a loader that accepts a CSV or a list of people, submits it, and writes one outcome per row — including the rows that were refused.

## Before you start

- A test key on the sandbox. Rows use the same test names; this guide batches **Exact Match**, **Clear Visitor**, **Name Only** and **Out State**.
- Three body shapes: a bare JSON array of `Query` objects; a JSON envelope `{"queries": [...], ...options}` where the options (`jurisdictions`, `match`, `recordTypes`, `locationScoped`, `include`, `deadlineMs`, `onDeadline`, `subjectId`, `clientId`, `clearanceMode`) apply to every row; or a `text/csv` body whose header row names `Query` fields.
- Batch is **Regular only**: no `live`, no `proof`. Each row is billed as one search ([pricing](/pricing)). Limits: more than 1,000 rows is a `413`; an empty batch is a `422`; a trial account whose remaining searches are fewer than the rows gets a `402`.

> **The one thing people get wrong.** A batch row carries **no `searchId`**. It cannot be cited by `POST /v1/report`, re-opened at `/results`, or named in a clearance. For a row you will need to cite — a hit you must document — run `POST /v1/search` for that person after the batch.

## Step by step

1. **Submit a JSON envelope.** Four rows and one batch-wide option. The response has `count` and `results[]`, one entry per row, `index` 0-based.
2. **Submit the same people as CSV.** Header row of `Query` field names, one person per line, `Content-Type: text/csv`. Unknown CSV columns are ignored.
3. **Read each row.** `status` is `complete`, `partial` or `error`. A `complete`/`partial` row has `counts`, `warnings` and `records`; an `error` row has `error` and `records: []`.
4. **Follow up on hits.** For each row with records you must act on, run `POST /v1/search` to get a citable `searchId`, then report or clear from it.

### Step 1 — JSON envelope

**cURL**
```bash
curl -s https://sandbox.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"queries": [
        {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
        {"firstName": "Clear", "lastName": "Visitor"},
        {"firstName": "Name",  "lastName": "Only"},
        {"firstName": "Out",   "lastName": "State", "state": "TX"}
      ],
      "match": "balanced", "onDeadline": "partial"}'
```

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

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}

def batch(queries, **options):
    assert 0 < len(queries) <= 1000
    r = requests.post(BASE + "/v1/batch", headers=H, json={"queries": queries, **options}, timeout=600)
    r.raise_for_status()
    return r.json()

def row_outcome(row):
    if row["status"] == "error":
        return "refused", row["error"]
    if row["counts"]["records"] == 0:
        return ("clear" if row["status"] == "complete" else "incomplete"), None
    return "candidates", [(r["personRef"], r["matchState"], r["matchConfidence"]) for r in row["records"]]

people = [
    {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
    {"firstName": "Clear", "lastName": "Visitor"},
    {"firstName": "Name", "lastName": "Only"},
    {"firstName": "Out", "lastName": "State", "state": "TX"},
]
out = batch(people, match="balanced", onDeadline="partial")
for row in out["results"]:
    print(row["index"], people[row["index"]]["lastName"], row_outcome(row))
```

**JavaScript**
```javascript
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };

async function batch(queries, options = {}) {
  if (!queries.length || queries.length > 1000) throw new Error('1..1000 rows');
  const res = await fetch(BASE + '/v1/batch', { method: 'POST', headers: H, body: JSON.stringify({ queries, ...options }) });
  if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
  return res.json();
}

function rowOutcome(row) {
  if (row.status === 'error') return ['refused', row.error];
  if (row.counts.records === 0) return [row.status === 'complete' ? 'clear' : 'incomplete', null];
  return ['candidates', row.records.map((r) => [r.personRef, r.matchState, r.matchConfidence])];
}

const people = [
  { firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' },
  { firstName: 'Clear', lastName: 'Visitor' },
  { firstName: 'Name', lastName: 'Only' },
  { firstName: 'Out', lastName: 'State', state: 'TX' },
];
const out = await batch(people, { match: 'balanced', onDeadline: 'partial' });
for (const row of out.results) console.log(row.index, people[row.index].lastName, rowOutcome(row));
```

*200 (trimmed: records cut to the deciding fields)*
```json
{
  "count": 4,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
      "warnings": [],
      "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "matchState": "dob_match", "matchConfidence": 0.9, "registrationState": "OH" } ] },
    { "index": 1, "status": "complete",
      "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
      "warnings": [], "records": [] },
    { "index": 2, "status": "complete",
      "counts": { "records": 3, "recordsReturned": 3, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
      "warnings": [],
      "records": [ { "personRef": "prs_fec5ef71d9deef5bc8c4", "matchState": null, "matchConfidence": 0.6, "registrationState": "AZ" },
                   { "personRef": "prs_ca91307a7124dca36777", "matchState": null, "matchConfidence": 0.6, "registrationState": "GA" },
                   { "personRef": "prs_84881c51922112e393d2", "matchState": null, "matchConfidence": 0.6, "registrationState": "MI" } ] },
    { "index": 3, "status": "complete",
      "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
      "warnings": [],
      "records": [ { "recordId": "rec_21b7a06132139de2ba35", "personRef": "prs_21b7a06132139de2ba35", "matchState": null, "matchConfidence": 0.9, "registrationState": "OK" } ] }
  ],
  "sandbox": true
}
```

### Step 2 — CSV

```bash
cat > roster.csv <<'CSV'
firstName,lastName,dob,state
Exact,Match,1980-01-01,
Clear,Visitor,,
Out,State,,TX
CSV
curl -s https://sandbox.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: text/csv" \
  --data-binary @roster.csv
```

The header names `Query` fields (`firstName,lastName,dob,age,city,county,state,zipcode,address,q,…`); the optional columns `subjectId`, `clientId` and `clearanceMode` turn [clearances](/docs/clearances) on for that row. Empty cells are empty values. The response is the same `{count, results[]}` shape.

### Step 3 — rows that are refused

**bad value → whole batch 422**
```json
// [{"firstName": "Exact", "lastName": "Match", "dob": "1980"}]
{ "detail": "Row 0: dob: Datetimes provided to dates should have zero time - e.g. be exact dates" }
```

**unknown key → whole batch 422**
```json
// [{"firstName": "Exact", "lastName": "Match", "zip": "00000"}]
{ "detail": "Row 0: Unknown query field(s) 'zip' in a JSON batch row. Allowed: address, age, ageTolerance, city, county, createdAtEnd, createdAtStart, dob, faceId, firstName, fuzzy, lastName, lat, lng, nameMatch, onAgeMismatch, page, perPage, prefixMatch, q, radiusMiles, state, updatedAtEnd, updatedAtStart, zipcode." }
```

**row refused while running → in place**
```json
// a row that fails validation at run time (e.g. firstName alone) is reported, not raised
{ "index": 5, "status": "error",
  "error": "This query has nothing to narrow on, so it cannot be answered completely: … A firstName on its own is not enough to narrow a search — pair it with a surname or a date of birth. …",
  "records": [] }
```

The distinction: a JSON row that **cannot be read** (wrong type, unknown key) makes the whole request a `422` whose `detail` names the row index, and nothing runs. A row that reads but is **refused when it runs** is reported in place with `status: "error"`, and the rest of the batch is unaffected. `error` is usually a sentence; for a coded refusal (such as the location-record quota) it is a `{code, message}` object.

## Reading the result

| Field | Values | What it decides per row |
| --- | --- | --- |
| `results[].index` | 0-based integer | Join back to your input; results are in input order, but key on this anyway. |
| `results[].status` | `complete`, `partial`, `error` | Same meaning as the envelope `status`; `error` means the row did not run. |
| `results[].counts` | the full `SearchResponse` counts | `sourcesIncomplete > 0` makes the row a lower bound. Check it **per row** — one row can be short while the rest are whole. |
| `results[].warnings[]` | sentences | The row's only prose completeness signal: a row carries no `sourceStatus`. |
| `results[].records[]` | `Record` | Read exactly as a synchronous result; `personRef`, `matchState`, `matchDetail` all present. |
| `results[].cleared[]`, `counts.clearedSuppressed` | only with `subjectId` on the row or envelope | What a `not_this_person` clearance removed from this row. |
| `results[].error` | string or `{code, message}` | Why the row did not run. |
| Not present | `searchId`, `sourceStatus`, `page*`, `capped*`, `proof` | Rows are not citable and not paged; see the callout above. |

## Edge cases and pitfalls

- **One hit per row does not mean one person.** A `Name Only` row returns three candidates; your loader must store a list per row, not a flag.
- **`live` in a row is a `422`** (unknown key); at the envelope level it is ignored. Batch is Regular. Re-check hits live with `POST /v1/search`.
- **CSV and JSON treat unknown fields differently**: CSV columns the API does not know are ignored; a JSON key it does not know refuses the batch. Validate your mapping on a one-row batch first.
- **A `dob` cell of `1980`** (year only) refuses the whole batch. Clean dates to `YYYY-MM-DD` or move the year to `age`.
- **Envelope options bind every row.** `jurisdictions` on the envelope scopes every search in it; `subjectId` on the envelope applies the same subject's clearances to every row — almost never what you want. Put `subjectId` on the row.
- **Timeouts.** A 1,000-row batch is 1,000 searches at bounded concurrency; set your HTTP timeout accordingly (minutes), or split into smaller batches you can retry independently.
- **Billing is per row**, not per request: 100 rows is 100 searches at the graduated per-call rate; a trial cannot run a batch larger than its remaining searches.

## Production checklist

- Rows validated locally against the `Query` field list before submit; dates normalised.
- Per-row `status`, `counts.sourcesIncomplete`, `warnings` and `records` stored by `index`.
- Hits that need citing are re-run with `POST /v1/search` and the `searchId` kept.
- Batches sized so one failure is cheap to retry; HTTP timeout sized to the batch.

## Related

- [Batch & CSV search](https://offendersearch.app/docs/batch)
- [Asynchronous searches](https://offendersearch.app/docs/guides/sex-offender/async-searches)
- [Clearances](https://offendersearch.app/docs/clearances)
- [Verification reports](https://offendersearch.app/docs/guides/sex-offender/verification-reports)
