Criminal Records API · v1.0.0

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.

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.
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 -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
  }'
200 — one result per row, in input order, trimmed
{
  "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

FieldValuesRead as
countintegerAlways equals the number of input rows.
results[].index0-based integerThe input position. Join on it; results are in input order but the index is the contract.
results[].statuscomplete · error · not_runRan · failed validation (or was too broad) · could not be searched within this request. Never partial: there is no live in batch.
results[].errorsentence, on error and not_runThe validation message, or the too-broad sentence, or the not-run notice.
results[].errorCodequery_too_broad, present only on that caseNarrow 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 runWhat to resubmit. Nothing was billed for these rows.
meterEvents[]one criminal_call per ran rowCount them to reconcile; error and not_run rows emit none.
row counts, records[], coverageas on a searchA 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.

Product overview