# Background check API vs building your own records pipeline: the build-or-buy decision

> What it costs to write and keep your own integrations against county court portals, jail rosters and state registry sites, what one API with one schema gives you instead, what it does not, and how to evaluate both with the same twenty names.

- **HTML:** https://offendersearch.app/docs/learn/background-check-api-vs-building-your-own
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## Background check API vs building your own records pipeline: the decision in one table

There are two ways to put public criminal and registry records into a product. You can write integrations yourself against the websites that publish them, one parser per county court portal, jail roster, corrections lookup and state registry, and keep those parsers working as each site changes. Or you can call one [background check API](/background-check-api) that answers in one schema and bill it per call. This page is for the engineer or product owner weighing those two, and it keeps to the questions you will actually face: maintenance, identity resolution, what you get, what you do not, cost, and when the build is still right.

| Question | Your own integrations | One API |
| --- | --- | --- |
| Integrations to write | One per site; a county may have several | One request per search |
| Schema | One per site, normalised by you | One, documented, same keys every time |
| When a site changes its HTML | Your parser breaks; you notice when results go quiet | Nothing changes for you |
| Identity across sources | Yours to solve, per site | `personRef`, `matchState`, `unverified` on every record |
| Completeness | Unknown unless you track it per site | `status`, `counts`, `sourceStatus` on every response |
| Citations | Store the URL yourself, per parser | `sources[].recordUrl` on every record |
| Terms | Check each site's terms and ask counsel | One agreement |
| Cost | Engineer-weeks to build, weeks a year to keep | $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card |

## What building your own court website integrations costs: parsers, formats and maintenance

The first parser is the cheap one. A county court portal with a name search and a results table takes an afternoon: submit the form, read the rows, follow the case link, pull the charges. The cost arrives afterwards, and it compounds. Counties move to new case-management vendors and the HTML changes completely. A sheriff's roster adds a consent page. A state corrections lookup starts answering with a JavaScript-rendered table instead of server HTML. A court changes its date format from `03/02/2015` to `2015-03-02` in one column and not the others. None of this is announced; you find out when a parser returns an empty list and nobody is sure whether that means "no record" or "broken".

Multiply that by the number of sites. There are thousands of county courts in the United States, and the jurisdictions you care about will each have a court portal, a jail roster and often a separate corrections lookup, in different vendors' formats. A team that keeps thirty of these working reliably has a standing maintenance job; a team that keeps three hundred working has a product, and it is a different product from the one they set out to build.

- **Format drift.** Dates, name order (`LAST, FIRST M` versus `First M Last`), charge codes, disposition vocabularies and case-number formats all vary per site and change over time.
- **Silent failure.** A broken parser and a clear subject look identical from the outside: both return nothing. Distinguishing them means per-site health checks, which is more code to keep.
- **Access controls.** Consent pages, rate limits, CAPTCHAs and login walls appear without notice. Each one is a per-site workaround and each workaround is more to maintain.
- **Detail pages.** The results list rarely carries the disposition. Getting it means a second request per hit, with its own parser and its own drift.
- **Normalisation.** Thirty sites give you thirty schemas. Folding them into one that your product can read is a project of its own, and the one that never quite finishes.

## Terms and exposure when automating court websites

Every site you automate has terms of use, and some are explicit about automated access. Check each site's terms and ask counsel. That is the whole of this section: it is a question for your lawyer, not for a tutorial, and the only engineering point is that the answer is per site, so it is one more thing that scales with the number of integrations you keep.

## Identity resolution: why one schema beats a pile of court website pages

The hard problem is not fetching a page; it is knowing whether the *Michael Johnson* on a county docket is your applicant. Sites differ in what birth evidence they publish: a full date, a year, an age, or nothing. A parser that treats a name match as a hit will flag the wrong person on every common name; a parser that only accepts a full-date match will miss a hit on a site that publishes years. Each site needs its own rule, and the rules must agree with each other or the same person is a hit in one county and a miss in the next.

An API that returns one schema has already made these decisions once and applies them identically to every record, so your code reads the same four fields whatever the source:

*One schema, four reads*
```javascript
// The same four reads, whichever source a record came from.
for (const rec of data.records) {
  const who   = rec.personRef;                       // one key per person, across sources
  const sure  = rec.matchState === "dob_match";      // the only automatic identification
  const kind  = rec.recordType;                      // court_case | jail_booking | doc_inmate | warrant | …
  const cites = rec.sources.map((s) => s.recordUrl); // where a reviewer confirms it
}
```

| Field | What it settles |
| --- | --- |
| `personRef` | One key per person across listings and sources, so the same subject found in two places is one record with two entries in `sources[]`, not two hits to reconcile. |
| `matchState` | The identification verdict: `dob_match` (full date agrees), `year_match`, `age_match`, `no_dob_age_year` (nothing to check; name only), `dob_near_match` (only with `dobTolerance`), or `null` when you sent no date. |
| `unverified` | One boolean: `false` only for `dob_match`. The field a review queue branches on. |
| `recordType` | Which schema the record follows: `sex_offender` on the registry side; `court_case`, `jail_booking`, `doc_inmate`, `warrant`, `wanted_fugitive`, `registry` or `healthcare_exclusion` on the criminal side. |
| `cases[].charges[].disposition` | Whether a charge ended in a conviction, a dismissal or is still open. The field that decides whether a court record counts. |

A record whose published date of birth contradicts yours is not returned at all, rather than returned with a low score, which removes a whole class of false positives before your code sees them. The [matching reference](/docs/criminal/matching) and the guide on [false positives](/docs/guides/criminal/false-positives) describe the rules; the point here is only that they are one set of rules.

## What a background check API gives you in one request

- **Registry breadth.** `POST /v1/search` answers from the 58 US registries in one request, with `counts.sourcesQueried` and one `sourceStatus` row per registry. See the sex offender API.
- **Criminal layers.** `POST /v1/criminal/search` returns court cases, jail bookings, corrections records, warrants and exclusions, each `sources[]` entry carrying a `level` of `county`, `state` or `federal`. See the [criminal records API](/criminal-records-api).
- **Completeness you can read.** `status` is `complete` or `partial`; `counts.sourcesIncomplete` and each `sourceStatus` row's `incomplete` and `incompleteReason` say exactly which part of the answer is a lower bound.
- **A citation on every record.** `sources[].recordUrl` is the page a reviewer opens, with `lastCheckedAt` beside it.
- **One schema.** Every key is always present on a criminal record, with `""`, `null` or `[]` when the source publishes nothing, so a parser can be strict.
- **A continuously updated dataset**, plus an optional `live` block when a decision needs a source re-verified now, at $0.02 per completed source.
- **Per-call pricing.** $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card. Batch rows bill the same way.
- **A sandbox** with documented test people and a free `os_test_` key, so the evaluation below costs nothing.

## What the background check API does not do

A fair comparison names the gaps, and these are the ones that decide the build for some teams:

- **No Social Security number.** The query is name, date of birth or age, and place. There is no SSN field and no SSN trace.
- **No credit.** Nothing about credit history, employment history or education is returned.
- **No international records.** The dataset is United States public records.
- **Not a consumer report.** Every criminal response carries `legal.notice`. A hiring, tenant, credit or insurance decision runs as an order through the [FCRA Partner API](/docs/fcra), which is a separate, approved-partner flow; consult counsel on which applies to you.
- **Not every field on every source page.** A record is normalised; if your product needs a field that only one portal publishes and the API does not return, that one portal may still be yours to integrate.

## Cost comparison: engineer-weeks vs per-call price

Do not compare a hosting bill with an API bill; the hosting is the small part of the build. The real build cost is engineering time, and it has two components. The first is the initial integrations: for each site, the parser, the detail-page parser, the normalisation into your schema, the health check and the identity rule. The second is the standing maintenance: every site that changes, every new access control, every quiet failure found a week late. Teams that have done this describe the first in engineer-weeks per handful of sites and the second as a fraction of an engineer, permanently, that grows with the number of sites kept alive.

| Cost line | Your own integrations | One API |
| --- | --- | --- |
| Initial build | Engineer-weeks per batch of sites, before the first useful result | An afternoon: one endpoint, one key, the sandbox |
| Ongoing | A standing share of an engineer; grows with each site | None beyond the integration you already keep |
| Per search | Compute and proxies, small | $0.15 a call, $0.11 past 2,000 calls in a month |
| First results | After the build | 25 free calls, no card |
| Completeness and identity | Built and maintained by you | In the response |

The arithmetic is simple once you write it down. Take the engineer-weeks you estimate for the build and the share of an engineer for maintenance, convert them at your loaded rate, and divide by the searches you expect in a year. That is your per-search build cost. If it is below the per-call price and stays below it as the site count grows, build. For most teams searching more than a few jurisdictions, it is not close, and the cost that decides it is the maintenance line, not the build line. The [pricing page](/background-check-api-pricing) has the per-call schedule for the volume side of the sum.

## When building your own records pipeline is still the right call

Sometimes it is, and this page should say so.

- **One jurisdiction, one site.** A product that only ever needs one county's docket has one parser to keep. The maintenance argument does not apply, and the API's breadth is not worth paying for.
- **A one-off research project.** If you need a dataset once, for a study or an audit, and will not run it again, the standing maintenance cost is zero and the build is a script.
- **A field the API does not return.** When the decision depends on something only one portal publishes, that portal is yours to integrate whatever else you buy.
- **A source outside the API's coverage.** The [criminal coverage](/docs/criminal/coverage) page lists the sources on file; if yours is not there, you know before you start.

The mixed answer is common and sensible: the API for breadth, identity and citations, and one or two of your own integrations for the places it does not reach.

## A background check API call you can run against the sandbox

This is the entire integration on the API side, against the [public API for testing](/sandbox) with a free `os_test_` key. It searches the sandbox person **Felony Record**, who has one court case with a felony burglary charge found guilty and a misdemeanour theft charge dismissed, and prints the four reads from above plus the citation. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration.

**cURL**
```bash
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}}'
```

**Python**
```python
import os, requests

BASE = "https://sandbox.offendersearch.app"          # os_test_… keys only; nothing is billed
KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]          # os_test_…

r = requests.post(
    f"{BASE}/v1/criminal/search",
    headers={"X-API-Key": KEY, "Content-Type": "application/json"},
    json={"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}},
    timeout=60,
)
r.raise_for_status()
data = r.json()
print(data["status"], data["counts"]["records"])          # complete 1
for rec in data["records"]:
    print(rec["recordType"], rec["matchState"], rec["personRef"])
    for case in rec["cases"]:
        for ch in case["charges"]:
            print("  ", ch["severity"], ch["description"], ch["disposition"])
    for src in rec["sources"]:
        print("  cite:", src["code"], src["level"], src["recordUrl"])
```

**Node**
```javascript
const BASE = "https://sandbox.offendersearch.app";   // os_test_… keys only; nothing is billed
const KEY = process.env.OFFENDERSEARCH_TEST_KEY;      // os_test_…

const res = await fetch(`${BASE}/v1/criminal/search`, {
  method: "POST",
  headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ query: { firstName: "Felony", lastName: "Record", dob: "1975-06-15" } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data.status, data.counts.records);            // complete 1
for (const rec of data.records) {
  console.log(rec.recordType, rec.matchState, rec.personRef);
  for (const c of rec.cases) for (const ch of c.charges) console.log("  ", ch.severity, ch.description, ch.disposition);
  for (const s of rec.sources) console.log("  cite:", s.code, s.level, s.recordUrl);
}
```

Change the body to `{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}}` and the path to `/v1/search` for the registry side. The sandbox allows 60 calls a minute and 2,000 a day per test key; the full list of test people is on the [sandbox reference](/docs/sandbox).

## How to evaluate the API against your own court website integrations

One controlled test settles the question for your product better than any table on this page.

1. **Define the scope.** Write down which record kinds you need (registry listings, court cases, bookings, custody, warrants), which jurisdictions, and what a decision needs from each record: a disposition, a date, a citation. This list is the specification for either option.
2. **Run the same twenty names through both.** Pick twenty subjects you already know the answer for, with dates of birth. Run them through your own integrations and through the API (on the sandbox first with the documented test names, then with 25 free production calls). Keep the raw responses.
3. **Compare identity, not just hit counts.** For each hit, note whether you could tell it was the same person: did the source publish a date of birth, did it agree, and would your parser have known? Compare that with `matchState` and `unverified` on the API record.
4. **Compare citations.** For each API record, open `sources[].recordUrl`. For each of your own hits, check you stored an equivalent link. A result a reviewer cannot confirm is not a result.
5. **Price it.** Count the engineer-weeks your twenty names cost to reach, and the weeks per year to keep those integrations working. Set that against the per-call price at your expected monthly volume.
6. **Decide per jurisdiction.** The answer can be mixed: an API for the breadth, and your own integration for one portal the API does not cover or where you need a field it does not return.

## Background check API vs building your own: questions developers ask

### Is it cheaper to build my own court records integration?

For one site, usually yes. For many, the maintenance line decides it: every site that changes its format is an interruption, and a parser that quietly returns nothing is indistinguishable from a clear subject until someone checks. Price the engineer-weeks and the standing maintenance against $0.15 a call at your volume.

### Does the API return the same records I would get from the court website?

It returns a normalised record with a citation to the source page in `sources[].recordUrl`. Run the twenty-name test above on your own jurisdictions to see the overlap for your scope; the [criminal coverage](/docs/criminal/coverage) page lists what is on file.

### How does the API tell me it is the same person?

`matchState` on every record: `dob_match` when the published full date of birth agrees with yours, and weaker states when the source publishes less. A record whose date contradicts yours is dropped. `personRef` is one key per person across sources.

### Can I use the API results for hiring or tenant screening?

Not directly: the standard endpoints are not a consumer report, and the criminal response says so in `legal.notice`. Those decisions run as orders through the [FCRA Partner API](/docs/fcra). Consult counsel.

### What if I need a jurisdiction the API does not cover?

Keep your own integration for that one and use the API for the rest. Nothing about the API stops you mixing; `sources[]` tells you which records it answered for, so your own hits are easy to merge in.

### Is there a free way to try the API before deciding?

Yes. The [sandbox](/sandbox) answers with documented test people on a free `os_test_` key, and the first 25 production calls are free with no card; see the [free background check API](/free-background-check-api) page.

## What this page does not cover

- How to write the request body in full: fields, `match` modes, `live` and `include` are in the criminal search reference and the [background check API tutorial](/docs/learn/background-check-api-tutorial).
- Reading every key of the response: see [the response explained](/docs/learn/background-check-api-response-explained).
- Legal questions about automating any site, or about using any record in a decision: those are for counsel.
- Comparing hosted screening vendors: that is the [Checkr API alternative](/checkr-api-alternative) page.

## Related

- [Background check API](https://offendersearch.app/background-check-api)
- [Criminal records API](https://offendersearch.app/criminal-records-api)
- [Sex offender API](https://offendersearch.app/sex-offender-api)
- [Background check API pricing](https://offendersearch.app/background-check-api-pricing)
- [Criminal coverage: sources on file](https://offendersearch.app/docs/criminal/coverage)
- [Criminal matching and matchState](https://offendersearch.app/docs/criminal/matching)
- [False positives on common names](https://offendersearch.app/docs/guides/criminal/false-positives)
- [The response explained field by field](https://offendersearch.app/docs/learn/background-check-api-response-explained)
- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Court records API tutorial](https://offendersearch.app/docs/learn/court-records-api-tutorial)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
