Offendersearch
Criminal API Reference

Batch & CSV search

Up to 1000 cached lookups per call, JSON or CSV, row in and row out, with per-row fault isolation.

Base URL https://api.offendersearch.app

One row in, one result out

Bulk cached lookups: each row runs through the same cached engine as POST /v1/criminal/search, so results are identical to running them individually, and every result carries the same Record schema and the same envelope — counts, warnings, pagination, labelled status. Live verification is not offered in batch — there is no live block on a batch row, and a batch never fans out to a live source, so every row is cache-only and every status is complete or error, never partial.

  • Three accepted body shapes. A bare JSON array of query objects, a JSON envelope { "queries": [...], ...defaults } 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, so you can zip results back onto your own rows without matching on identity.
  • Rows are fault-isolated. A row that fails validation is returned with status: "error" in place and never aborts the rest of the batch — and is not billed.
  • 1000 rows per call. A larger batch returns 413 batch_too_large; a batch of zero rows is a 422 empty_batch.
Each row is billed as one call. A 1,000-row batch is 1,000 calls — meterEvents rolls up as a single criminal_call whose quantity is the number of rows that ran. A row that fails validation is not metered, so quantity can be lower than count.

The JSON form

Post either a bare array of query objects, or an envelope { "queries": [...], ...defaults } whose non-queries keys are batch-wide defaults applied to every row. A per-row value overrides the default for that row — so nameStrategy: "prefix" at the envelope level applies to all rows unless a row sets its own. Each query object is flat — the fields from a query, not wrapped in { "query": … }.

Bare array
POST /v1/criminal/batch
curl -X POST https://api.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '[
        { "lastName": "Hamilton", "state": "TX" },
        { "firstName": "Alex", "state": "TX" }
      ]'
Envelope with batch-wide defaults
queries + defaults
{
  "queries": [
    { "lastName": "Hamilton", "state": "TX" },
    { "lastName": "Example",  "state": "IL", "firstName": "Chris" }
  ],
  "include": ["sourceData"],
  "nameStrategy": "prefix"
}

The CSV form

Post a text/csv body whose header row names query fields, one query per line. Empty cells are dropped (they do not override a batch-wide default), and a UTF-8 BOM is handled. Unknown columns are ignored, so you can post a CSV export unchanged — a caseRef or note column is dropped rather than rejected. The endpoint still requires lastName per row.

Request — a two-row CSV, second row has no surname
POST /v1/criminal/batch (text/csv)
curl -X POST https://api.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary $'firstName,lastName,state,caseRef\nAlex,Hamilton,TX,FILE-0142\nJordan,,IL,FILE-0143\n'
Response — one envelope per row, in order
200 OK
{
  "count": 2,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 12, "recordsReturned": 12, "sourcesQueried": 0,
                  "sourcesComplete": 0, "sourcesIncomplete": 0 },
      "warnings": [], "page": 1, "perPage": 50, "totalPages": 1,
      "sourceStatus": [], "cappedOmittedSources": [], "omittedFields": ["sourceData"],
      "records": [],
      "legal": { "notice": "Not a consumer report. …" } },
    { "index": 1, "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 },
      "records": [] }
  ],
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null } ]
}
Read each row’s own result. Row 0 ran and carries a full envelope with its own counts, exactly as a single search does — the unknown caseRef column was ignored. Row 1 has no surname, so it is status: "error" with the reason in place, and it is not billed: meterEvents.quantity is 1, for the one row that ran, even though count is 2.

Guardrails & unbounded rows

A batch is one HTTP call, but each row is a full search and each row is validated on its own. The whole-batch guardrails are size limits; the per-row guardrail is the same surname requirement as a single search.

LimitValueOver-limit behaviour
Max rows1000413 batch_too_large
Zero rows422 empty_batch
Unparseable body / no CSV header422 bad_batch
An unbounded row surfaces as status: "error", not a lottery. A row with no lastName would be an unbounded query, so the row is returned with status: "error", its index in place, zeroed counts, an empty records, and an error string — and it is not billed. The rest of the batch still runs. A bad row is a row you can fix and resubmit, never a failure that costs you the whole call.