# Batch & CSV search

> Run up to 1000 lookups in one call. Post a JSON array, a JSON envelope with batch-wide options, or a text/csv body; results return in order, fault-isolated.

- **HTML:** https://offendersearch.app/docs/batch
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## One row in, one result out

The batch endpoint is the shape to reach for when you already hold a list: a roster to screen, a CSV export to reconcile, a nightly re-check of a population. Each row runs through the same engine as `POST /v1/search`, so every result carries the same envelope — `counts`, `sourceStatus`, labelled `matchState` — and the same Record schema.

- **Three accepted body shapes.** A bare JSON array of `Query` objects, a JSON envelope `{ "queries": [...], ...options }` whose non-`queries` keys apply to every row, or a `text/csv` body whose header row names Query fields.
- **Input order is preserved.** Each result carries its 0-based `index`.
- **Rows are fault-isolated.** A row that fails is reported with `status: "error"` in place and never aborts the rest of the batch.
- **1000 rows per call.** A larger batch returns `413`.

**Billing is per search, not per request.** A batch is one HTTP call, but each search in it is metered as one search — a batch of 100 rows is 100 metered searches. Set `freshness: "weekly"` on a row, or batch-wide, to meter that search with no surcharge.

## POST /v1/batch — Batch / CSV search

Run many lookups in one call — row in, row out. Send a JSON array of queries, a JSON envelope with batch-wide options, or a text/csv body. Each row runs through the same engine as /v1/search and results come back in input order.

**Authentication:** `X-API-Key` header.

The batch endpoint provides legacy CSV-batch parity: **one row = one call in, one result out**. Post a JSON array of `Query` objects, a JSON envelope `{ "queries": [...], ...options }` where the non-`queries` keys (`jurisdictions`, `freshness`, `match`, `recordTypes`, `locationScoped`, `include`) apply to every row, or a `text/csv` body whose header row names Query fields (`firstName,lastName,state,dob,city,zipcode,address,age,q`).

Rows run with bounded server-side concurrency and results return in the same order you sent them. A row that fails is reported with `status: "error"` **in place** — it never aborts the rest of the batch. The maximum is **1000 rows** per call (a larger batch returns `413`).

**Billing is per search, not per request.** A batch is one HTTP call but each search in it is billed as one search — a batch of 100 rows = 100 metered searches, each at the graduated per-call rate plus its own +$0.01 daily-freshness surcharge when that row is `daily` (the default). Set `freshness: "weekly"` on a row (or batch-wide) to bill that search with no surcharge.

Every row is served at the freshness tier it asks for. `weekly` is the natural fit here — bulk and periodic re-screening work is exactly what it is for, and it carries no surcharge; set `daily` on the rows where currency decides the outcome. For very large or long-running workloads, the asynchronous endpoint is the other batch-shaped option: submit and collect, with no request-timeout ceiling.

### What you can do

- **JSON or CSV.** Post a JSON array, a JSON envelope with batch-wide options, or a text/csv body — whatever your pipeline already produces.
- **Row-in / row-out.** Results return in input order, each tagged with its 0-based index.
- **Fault-isolated.** A row that errors is reported with status:"error" in place and never aborts the batch.
- **Up to 1000 rows.** One call handles up to 1000 queries; larger batches return 413.

### Body

Send one of: a JSON array of Query objects, a JSON envelope, or a text/csv body.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `queries` | `Query[]` | required | The per-row queries (envelope form). Or post a bare JSON array, or a CSV body. |
| `jurisdictions / freshness / match / recordTypes / locationScoped / include` | `various` | optional | Batch-wide options in the envelope form — applied to every row. |

### Request

**cURL**

```bash
# JSON envelope: batch-wide options apply to every row
curl https://api.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "queries": [
      { "firstName": "John", "lastName": "Smith", "state": "FL" },
      { "firstName": "Jane", "lastName": "Doe",   "state": "TX" }
    ],
    "freshness": "daily",
    "match": "balanced"
  }'

# …or upload a CSV whose header row names Query fields
curl https://api.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary $'firstName,lastName,state\nJohn,Smith,FL\nJane,Doe,TX'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/batch", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    queries: [
      { firstName: "John", lastName: "Smith", state: "FL" },
      { firstName: "Jane", lastName: "Doe", state: "TX" },
    ],
    freshness: "daily",
    match: "balanced",
  }),
});

const { count, results } = await res.json();
// results come back in input order; a failed row is status:"error", never aborts the batch
for (const r of results) console.log(r.index, r.status, r.counts?.records ?? 0);
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/batch",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "queries": [
            {"firstName": "John", "lastName": "Smith", "state": "FL"},
            {"firstName": "Jane", "lastName": "Doe", "state": "TX"},
        ],
        "freshness": "daily",
        "match": "balanced",
    },
)
data = resp.json()
print(data["count"], "rows")
for r in data["results"]:
    print(r["index"], r["status"], r.get("counts", {}).get("records", 0))
```

### Response

```json
{
  "count": 2,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 3 },
      "records": [ /* … scored, de-duplicated records … */ ] },
    { "index": 1, "status": "complete",
      "counts": { "records": 0, "sourcesQueried": 3 }, "records": [] }
  ]
}
```

Results are in input order. A failed row is { "index": n, "status": "error", "error": "…" } in place. Returns 413 if the batch exceeds 1000 rows.

## The CSV form

Post a `text/csv` body whose header row names `Query` fields — `firstName`, `lastName`, `state`, `dob`, `city`, `zipcode`, `address`, `age`, `q`. Every row is a query, and the response is the same JSON envelope as the JSON form.

```bash
curl https://api.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @roster.csv

# roster.csv
# firstName,lastName,state,dob
# John,Smith,FL,1980-04-12
# Jane,Doe,TX,
```

```json
{
  "count": 2,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 58,
                  "sourcesComplete": 58, "sourcesIncomplete": 0 },
      "records": [] },
    { "index": 1, "status": "partial",
      "counts": { "records": 0, "sourcesQueried": 58,
                  "sourcesComplete": 57, "sourcesIncomplete": 1 },
      "records": [] }
  ]
}
```

**Read each row’s own counts.** Row-level completeness is reported per row, exactly as it is on a single search — row 1 above returned no records, but one jurisdiction did not complete, so it is *not determined* rather than *no match*.

## Batch or async?

|  | Batch | Async |
| --- | --- | --- |
| Unit | Many queries, one call | One query, collected later |
| Delivery | In the response, in input order | Poll or signed webhook |
| Ceiling | 1000 rows per call | No request-timeout ceiling |
| Use it for | Rosters, CSV reconciliation, scheduled re-screens | Very broad single searches, unattended jobs |

---

## Related

- Previous: [Async search, webhooks & idempotency](https://offendersearch.app/docs/async-and-webhooks.md)
- Next: [The Record object](https://offendersearch.app/docs/record-object.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
