Sex Offender Search API · v1.0.0

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.

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). 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 -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"}'
200 (trimmed: records cut to the deciding fields)
{
  "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

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 on for that row. Empty cells are empty values. The response is the same {count, results[]} shape.

Step 3 — rows that are refused

// [{"firstName": "Exact", "lastName": "Match", "dob": "1980"}]
{ "detail": "Row 0: dob: Datetimes provided to dates should have zero time - e.g. be exact dates" }

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

FieldValuesWhat it decides per row
results[].index0-based integerJoin back to your input; results are in input order, but key on this anyway.
results[].statuscomplete, partial, errorSame meaning as the envelope status; error means the row did not run.
results[].countsthe full SearchResponse countssourcesIncomplete > 0 makes the row a lower bound. Check it per row — one row can be short while the rest are whole.
results[].warnings[]sentencesThe row's only prose completeness signal: a row carries no sourceStatus.
results[].records[]RecordRead exactly as a synchronous result; personRef, matchState, matchDetail all present.
results[].cleared[], counts.clearedSuppressedonly with subjectId on the row or envelopeWhat a not_this_person clearance removed from this row.
results[].errorstring or {code, message}Why the row did not run.
Not presentsearchId, sourceStatus, page*, capped*, proofRows 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.

Product overview