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 asqueryon 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 besidequeriesapply to every row, which is where a batch-widematchmode 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.
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.
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.csvBoth 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.
// 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": [] }| Read | Then |
|---|---|
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 complete | No registrant matched the row across counts.sourcesQueried registries. Say "0 of 58 matched", not "clean". |
A record with matchState dob_match and unverified false | An 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 dob | A name match only. Route it to a reviewer with the registry link; never auto-flag. |
matchBasis contains alias:exact, matchedName.type is alias | The 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.
# 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:
| Roster | Calls | Arithmetic | Month total |
|---|---|---|---|
| 1,000 people, once | 1,000 | 1,000 × $0.15 | $150 |
| 2,000 people, once | 2,000 | 2,000 × $0.15 | $300 |
| 25,000 people, once | 25,000 | 2,000 × $0.15 + 23,000 × $0.11 | $300 + $2,530 = $2,830 |
| 25,000 people, registry and criminal | 50,000 | 2,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.
# 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
- 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,dobas YYYY-MM-DD. Keep your own id in a column you join on afterwards. - Mint a test key and run the sample file on the sandbox. Create an
os_test_key in the dashboard, export it asOFFENDERSEARCH_TEST_KEY, and POST the four sample rows (Clear Visitor; Exact Match 1980-01-01; Name Only; Alias Match) astext/csvtohttps://sandbox.offendersearch.app/v1/batch. Nothing is billed. - Read results[] in input order. Each entry carries
index,status(complete, partial or error),countsandrecords[]. Join onindexto your input. A row withstatus"error" carrieserrorin place; the other rows ran. - Read each row's counts and matchState.
counts.recordsis how many people matched;counts.sourcesIncompleteabove zero makes a zero a lower bound, not a clear. On each record,dob_matchis an identification;no_dob_age_yearis a lead for a reviewer. - Write the results back to CSV. One line per matched record (or one per row with no match) with
recordId,personRef,matchState,matchConfidenceandsources[].recordUrl, so a reviewer can open the registry page. - 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-Afteron a429(nothing ran, nothing was billed) and never fire the chunks in parallel. - 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.alertwhen 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.