Developer docs · v1.0.0

Bulk background check API: run a CSV file through POST /v1/batch

Send a roster as JSON or as a text/csv body, get one result per row in input order, write recordId, personRef, matchState and the source link back to a CSV, and chunk a 25,000-row file without a burst.

Updated November 3, 2026

How a bulk background check works: POST /v1/batch and its three body shapes

A bulk background check is many single searches in one HTTP call. POST /v1/batch takes up to 1,000 rows, runs each row through the same engine as POST /v1/search, and returns one result per row in input order. Each row is billed as one search: at $0.15 a call, $0.11 past 2,000 calls in a month, with 25 free calls on sign-up and no card, a 1,000-row batch is 1,000 billable searches, not one. The sandbox at https://sandbox.offendersearch.app with an os_test_ key runs the same endpoint for free, at 60 calls a minute and 2,000 a day per key. Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls below are the integration.

Three body shapes are accepted, and they produce the same result:

  • A JSON array of Query objects: [{"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}, …]. Each object takes the same fields as query on a single search: firstName, lastName, dob, age, city, county, state, zipcode, address, q.
  • A JSON envelope: {"queries": [...], "match": "balanced", "jurisdictions": ["OH"], "recordTypes": [...], "locationScoped": false, "include": ["stateData"]}. The keys beside queries apply to every row, which is where a batch-wide match mode or a jurisdiction scope goes.
  • A `text/csv` body whose header row names Query fields: firstName,lastName,state,dob,city,zipcode,address,age,q. One person per line. This is the shape for a file you were handed, and the one the curl tab below sends.

Validation is strict so that a billed batch is a correct one. A JSON row that cannot be read (an unknown key, or a value of the wrong type such as "dob": "1980") makes the whole request a 422 whose detail names the row index and the field, and nothing runs. A row that fails while it runs is reported in place with status: "error" and never aborts the batch. More than 1,000 rows is a 413. Batch is cache-only: live re-verification is not offered per row, so a row you need to cite or re-verify is a single POST /v1/search. The batch reference has the full contract; the bulk sex offender search page describes the product.

Per-row clearances. A JSON row or a CSV column may carry subjectId, and optionally clientId and clearanceMode: each row is then one subject being screened, a person already cleared as not_this_person leaves that row's records[] for its cleared[], and counts.clearedSuppressed says how many. A row value wins over the same key at the envelope level.

Background check from a CSV file: the request

The sample file uses the sandbox magic names so you can see every outcome in one call: "Clear Visitor" returns zero records; "Exact Match" with 1980-01-01 returns one record identified by date of birth; "Name Only" returns three possible matches in three states with no date of birth on any; "Alias Match" returns one record matched through an alias. A row with no date of birth runs as a name-only search.

people.csv
firstName,lastName,dob
Clear,Visitor,
Exact,Match,1980-01-01
Name,Only,
Alias,Match,

The curl tab posts the file as text/csv. The Python and Node tabs read the file themselves, keep only the columns the API knows, and send the JSON envelope, which is the shape to use once you add subjectId or a batch-wide match; both write a results CSV and both chunk at 1,000 rows.

# A bulk background check from a CSV file, on the sandbox (free).
# The header row names Query fields; one person per line; up to 1,000 rows per call.
curl -X POST https://sandbox.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @people.csv

Both programs call https://sandbox.offendersearch.app with the key from OFFENDERSEARCH_TEST_KEY; a production run swaps the host for https://api.offendersearch.app and the key for an os_live_ one. On production, a trial account cannot run a batch (402): add billing first. The background check API pricing page has the graduated rate the cost section below applies.

Reading each bulk result row: status, counts and matchState

The response is { "count", "results": [...] }. Each entry in results[] carries index (the 0-based input row), status ("complete", "partial" or "error"), counts, warnings[] and records[]; a row that sent subjectId also carries cleared[]. A batch row carries no searchId, no sourceStatus[], no page keys and no proof, so counts.sourcesIncomplete and warnings[] are the only completeness signals a row has, and you must read them per row: one row can be short while the rest are whole.

The four sample rows (abridged)
// 200 from POST /v1/batch for the four sample rows (abridged). results[] is in input order.
{
  "count": 4,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 0 },
      "warnings": [], "records": [] },
    { "index": 1, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 1 },
      "warnings": [],
      "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "recordType": "sex_offender",
                     "matchState": "dob_match", "unverified": false, "matchConfidence": 0.9,
                     "name": { "full": "Exact Sample Match" }, "dob": "1980-01-01", "registrationState": "OH",
                     "sources": [ { "jurisdiction": "OH", "recordUrl": "https://offendersearch.app/docs/sandbox", "lastCheckedAt": "2026-10-01T00:00:00+00:00" } ] } ] },
    { "index": 2, "status": "complete",
      "counts": { "records": 3, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 3 },
      "warnings": [],
      "records": [ /* three records in three states, matchState "no_dob_age_year", unverified true */ ] },
    { "index": 3, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 58, "sourcesIncomplete": 0, "sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 1 },
      "warnings": [],
      "records": [ /* one record matched through an alias: matchedName.type "alias", matchBasis includes "alias:exact" */ ] }
  ]
}

// A row that failed while it ran is reported in place and never aborts the batch:
{ "index": 7, "status": "error", "error": "…", "counts": { "records": 0, "sourcesQueried": 0, "sourcesIncomplete": 0, "sourcesComplete": 0, "sourcesSkippedByScope": 0, "recordsReturned": 0 }, "warnings": [], "records": [] }
ReadThen
status is "error"The row did not run; error says why. Fix the input and resend that row alone. The other rows ran and were billed.
counts.sourcesIncomplete > 0, or status is "partial"The row is a lower bound. A zero here is not a clear; queue the row for a single POST /v1/search later.
counts.records is 0 and the row is completeNo registrant matched the row across counts.sourcesQueried registries. Say "0 of 58 matched", not "clean".
A record with matchState dob_match and unverified falseAn identification: the full date of birth agrees. Act on it.
A record with matchState no_dob_age_year, or null when the row sent no dobA name match only. Route it to a reviewer with the registry link; never auto-flag.
matchBasis contains alias:exact, matchedName.type is aliasThe person was found under another name they are registered with. Show the matched alias beside the legal name.

The background check API response explained tutorial walks every key on a single-search record; batch rows carry the same records.

Writing bulk background check results back to a CSV

A results file is what the person who gave you the roster can act on, so write one line per matched record, with the input row beside it. The columns that matter: your index (or your own id, joined through it), the input name and date of birth, the row's status and counts.records, counts.sourcesIncomplete, and then per record recordId, personRef, matchState, matchConfidence and sources[].recordUrl. The code above writes exactly that. A row with no match gets one line with the record columns empty, and a row with status: "error" gets one line with the error text, so the output always has at least as many lines as the input and nobody is silently missing.

recordId is what GET /v1/records/{recordId} fetches when a reviewer opens a line; personRef is the stable reference to the person behind the record, which is what you store if you will screen the same roster again; sources[].recordUrl is the official registry page a reviewer confirms against before anything is acted on.

Chunking a 25,000-row roster: 25 bulk calls, not a burst

A 25,000-row roster is 25 calls of 1,000 rows. The temptation is to fire all 25 at once; do not. Each batch already runs its rows with bounded concurrency on the server, and 25 of them in parallel is the shape that earns a 429 (detail.code "overloaded", with a Retry-After header of 1 to 120 seconds), at which point nothing in that call was searched or billed and you have to resend it anyway. Run the chunks through a pool of two or three workers, as the Node example does with POOL = 2, honour Retry-After on a 429, retry a 502, 503 or 504 with backoff, and never retry a 422: it is the same malformed row every time. The full retry table is in rate limits, retries and idempotency.

Idempotency is the other reason to keep the pool small. POST /v1/batch has no idempotency header: a batch you resend because a timeout ate the response is a second billed batch. Set a generous client timeout (the Python example uses 300 seconds), persist each chunk's results[] as soon as it arrives, and resume from the first chunk you have no results for, not from the start. Overnight is the right time to run a roster this size, when your own traffic is low; a 25,000-row screen is not a job for the middle of a working day.

Bulk criminal record checks: POST /v1/criminal/batch

The criminal records API has the same batch shape at POST /v1/criminal/batch: a JSON array of query objects, a JSON envelope { "queries": [...], ...batch-wide defaults } where any other key (include, state, perPage…) applies to every row and a per-row value overrides it, or a text/csv body whose header row names query field names. Up to 1,000 rows, results in input order, each row that ran billed as one call. Two differences from the registry batch: lastName is required on every row, and a row that fails validation (a missing lastName, say) is returned in place with status: "error" and its reason rather than failing the request. Criminal batch is cached only; live verification is not offered per row.

POST /v1/criminal/batch
# The criminal side: same idea, lastName required per row, cached only, one criminal_call per row that ran
curl -X POST https://sandbox.offendersearch.app/v1/criminal/batch \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries": [{"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"},
                   {"firstName": "Clear", "lastName": "Record"},
                   {"firstName": "NoSurname"}],
       "include": ["sourceData"]}'

# 200 — NOT the standard envelope: a wrapper with one entry per input row, in input order
{ "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": [], "omittedFields": [],
      "records": [ { "recordType": "court_case", "matchState": "dob_match", "cases": [ { "charges": [ { "description": "Burglary", "severity": "felony", "disposition": "Guilty" } ] } ] } ] },
    { "index": 1, "status": "complete", "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false }, "warnings": [], "records": [] },
    { "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": [] }
  ] }

# A CSV body works here too: a header row of query field names (firstName,lastName,dob,state,county …);
# empty cells are dropped, a UTF-8 BOM is handled, unknown columns are ignored.
# 413 { "error": { "code": "batch_too_large" } } above 1,000 rows; 422 bad_batch / empty_batch for an unreadable or empty body.

A criminal row is the criminal search envelope without the per-request keys: status, counts (records, recordsReturned, sourcesQueried, sourcesComplete, sourcesIncomplete, candidatesCapped, exclusionsIncluded), warnings[], page, perPage, totalPages, sourceStatus[], omittedFields[] and records[]. Read each record's recordType and matchState, then cases[].charges[].disposition, because a jail_booking with an empty disposition is an arrest and not a conviction; the search criminal records by name and date of birth tutorial reads one record in full. The criminal batch reference and the bulk criminal record search page cover the rest.

Bulk background check cost, and the re-screen cadence with monitors

Cost is arithmetic on the per-search rate, because a batch is billed per row. With $0.15 a call for the first 2,000 calls in a month and $0.11 for every call past 2,000:

RosterCallsArithmeticMonth total
1,000 people, once1,0001,000 × $0.15$150
2,000 people, once2,0002,000 × $0.15$300
25,000 people, once25,0002,000 × $0.15 + 23,000 × $0.11$300 + $2,530 = $2,830
25,000 people, registry and criminal50,0002,000 × $0.15 + 48,000 × $0.11$300 + $5,280 = $5,580

The number that changes the economics is how often you re-run it. Re-screening a 25,000-row roster monthly is 25,000 billed searches a month to find the handful of people whose status changed. A person monitor inverts that: POST /v1/monitors with product: "sex-offender" (or "criminal"), type: "person", the person and a channels.webhookUrl creates a standing watch that is evaluated continuously and sends a monitor.alert (event "new", "changed" or "removed", with detail.personRef and detail.recordId) only when something changes. $3 a month per person monitor, $10 per location monitor. Run the batch once to establish the baseline, then put monitors on the subjects you keep screening; the monitor a person with webhooks tutorial covers the alert and the signature.

A monitor per subject instead of a monthly re-run
# Re-screen cadence without re-running the batch: one person monitor per subject you keep screening
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" -H "Content-Type: application/json" \
  -d '{"product": "sex-offender", "type": "person", "label": "roster: row 1",
       "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "minConfidence": "dob_match",
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'
# 201 … "channels": {"webhookSecret": "whsec_…"} shown once. On the sandbox this monitor fires one "new" alert at once.

Bulk background check from a CSV file, step by step

  1. Prepare the CSV with the fields the API reads. A header row of Query field names (firstName,lastName,state,dob,city,zipcode,address,age,q), one person per line, dob as YYYY-MM-DD. Keep your own id in a column you join on afterwards.
  2. Mint a test key and run the sample file on the sandbox. Create an os_test_ key in the dashboard, export it as OFFENDERSEARCH_TEST_KEY, and POST the four sample rows (Clear Visitor; Exact Match 1980-01-01; Name Only; Alias Match) as text/csv to https://sandbox.offendersearch.app/v1/batch. Nothing is billed.
  3. Read results[] in input order. Each entry carries index, status (complete, partial or error), counts and records[]. Join on index to your input. A row with status "error" carries error in place; the other rows ran.
  4. Read each row's counts and matchState. counts.records is how many people matched; counts.sourcesIncomplete above zero makes a zero a lower bound, not a clear. On each record, dob_match is an identification; no_dob_age_year is a lead for a reviewer.
  5. Write the results back to CSV. One line per matched record (or one per row with no match) with recordId, personRef, matchState, matchConfidence and sources[].recordUrl, so a reviewer can open the registry page.
  6. Chunk a large roster into 1,000-row calls. 25,000 rows is 25 calls. Run them through a pool of two or three workers, honour Retry-After on a 429 (nothing ran, nothing was billed) and never fire the chunks in parallel.
  7. Set the re-screen cadence with monitors. Instead of re-running the batch, create a person monitor per subject you keep screening; it watches continuously and sends a monitor.alert when something changes. $3 a month per person monitor.

A batch result is a public-records lookup, not a consumer report. A roster screened for hiring, tenancy, credit or insurance runs as FCRA orders on the FCRA Partner API, one order per subject with consent and a permissible purpose recorded; whether your use needs that is a question for your counsel. The background check API page describes both layers, and Learn APIs by example has the rest of this series.

Bulk background check API: questions developers ask

Can I run a background check from a CSV file directly?

Yes. POST /v1/batch accepts a text/csv body whose header row names Query fields (firstName,lastName,state,dob,city,zipcode,address,age,q), one person per line, up to 1,000 rows. The results come back as JSON in input order.

How many rows can one bulk background check hold?

1,000. More is a 413. A larger roster is several calls, run through a small worker pool; a 25,000-row file is 25 calls.

How is a bulk background check billed?

Per row, not per request: each row is one search at $0.15 a call, $0.11 past 2,000 calls in a month. A 1,000-row batch is 1,000 billable searches. On the sandbox with an os_test_ key nothing is billed.

What happens when one row is bad?

It depends on when it fails. A JSON row that cannot be read (an unknown key, a wrongly typed value) is a 422 for the whole request, with detail naming the row index and the field, and nothing runs. A row that fails while it runs is status: "error" in place and the other rows are unaffected. On POST /v1/criminal/batch a row that fails validation is also reported in place.

Are the results in the same order as my file?

Yes, always, and each entry carries index, the 0-based input row, so you can join without relying on order.

Can a batch row be re-verified live or cited in a report?

No. Batch is cache-only and a row carries no searchId. For a row you need to cite with POST /v1/report, or re-verify with a live block, run that person as a single POST /v1/search.

Should I re-run the batch every month?

Usually not. Run it once for the baseline and put a person monitor on each subject you keep screening; the monitor is evaluated continuously and sends an alert only when something changes, at $3 a month per person monitor.

What this page does not cover

  • Async single searches and their webhookUrl (POST /v1/searches): the async and webhooks reference.
  • The record keys beyond the match and citation fields: background check API response explained.
  • Verifying the monitor webhook signature and handling retries: the webhook example.
  • Screening a roster for hiring or tenancy as FCRA orders: the FCRA orders reference under /docs/fcra.