# Date-of-birth matching

> One query reaches every kind of birth evidence a record publishes: a full date, a year, or an age. The record tells you which one it had.

- **HTML:** https://offendersearch.app/docs/guides/criminal/date-of-birth-matching
- **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

## What you'll build

The part of a screening integration that decides what birth evidence to send and how to read what came back. Sources differ: a court docket usually publishes a full date, a prison roster a year, a jail roster often only an age at booking, and an exclusion list sometimes nothing at all. The API matches your query against whichever the record has and labels the outcome on every record, so you can build one rule set instead of one per source.

## Before you start

- A test key (`os_test_…`) on `https://sandbox.offendersearch.app`.
- `Felony Record`, born `1975-06-15`, for the full-date, year-only and contradicting-date cases; `Possible Match` for records with no birth evidence; `Sanctioned Provider` (with `include: ["exclusions"]`) for an exclusion-list entry with none.
- The field reference: [Date of birth](/docs/criminal/date-of-birth).

> **A record with nothing to compare is kept, not dropped.** Whether you send `dob`, `birthYear` or `age`, a record that publishes no birth evidence stays in `records[]` as `matchState: "no_dob_age_year"`, `unverified: true`. Only a contradiction removes a record. If your logic assumes "I sent a date, so every result is confirmed", it is wrong.

## Step by step

1. **Send the strongest evidence you hold.** `dob` (`YYYY-MM-DD`) when you have it — year and month are derived from it. `birthYear` (+ optional `birthMonth`) when you hold only a year. `age` when that is all a form captured. Send one of them; `dob` wins if several are present.
2. **Read dob.dobPrecision on each record.** `full`, `month_year`, `year`, `age`, `none` says what the record itself publishes. It explains why the same query gives `dob_match` on one record and `year_match` on the next.
3. **Read matchState and the dob: token.** `dob:match` (full date equal), `dob:monthYear`, `dob:year`, `dob:ageFromDob` (your date against a published age), `dob:age` (your age against anything), `dob:absent`.
4. **Test the contradiction path.** Send `Felony Record` with `dob: "1990-01-01"`. The answer is complete with zero records: a `dob_mismatch` is dropped, never returned.

**Python**
```python
import requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"

def run(label, query, **top):
    r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json={"query": query, **top})
    r.raise_for_status()
    env = r.json()
    print(f"{label}: {env['counts']['records']} record(s)")
    for rec in env["records"]:
        print(f"   precision={rec['dob']['dobPrecision']:10} state={rec['matchState']:16} "
              f"conf={rec['matchConfidence']:.2f} basis={rec['matchBasis']}")

run("full date      ", {"lastName": "Record", "firstName": "Felony", "dob": "1975-06-15"})
run("year only      ", {"lastName": "Record", "firstName": "Felony", "birthYear": 1975})
run("wrong date     ", {"lastName": "Record", "firstName": "Felony", "dob": "1990-01-01"})
run("no evidence    ", {"lastName": "Match", "firstName": "Possible", "dob": "1988-04-12"})
run("exclusion, none", {"lastName": "Provider", "firstName": "Sanctioned", "dob": "1988-04-12"}, include=["exclusions"])
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const KEY = 'os_test_your_key';

async function run(label, query, top = {}) {
  const r = await fetch(API + '/v1/criminal/search', {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, ...top }),
  });
  if (!r.ok) throw new Error('HTTP ' + r.status);
  const env = await r.json();
  console.log(label + ': ' + env.counts.records + ' record(s)');
  for (const rec of env.records) {
    console.log('   precision=' + rec.dob.dobPrecision + ' state=' + rec.matchState +
      ' conf=' + rec.matchConfidence + ' basis=' + rec.matchBasis);
  }
}

await run('full date', { lastName: 'Record', firstName: 'Felony', dob: '1975-06-15' });
await run('year only', { lastName: 'Record', firstName: 'Felony', birthYear: 1975 });
await run('wrong date', { lastName: 'Record', firstName: 'Felony', dob: '1990-01-01' });
await run('no evidence', { lastName: 'Match', firstName: 'Possible', dob: '1988-04-12' });
await run('exclusion, none', { lastName: 'Provider', firstName: 'Sanctioned', dob: '1988-04-12' }, { include: ['exclusions'] });
```

*What the five runs print*
```plain
full date      : 1 record(s)
   precision=full       state=dob_match        conf=0.99 basis=lastName:exact firstName:exact dob:match
year only      : 1 record(s)
   precision=full       state=year_match       conf=0.85 basis=lastName:exact firstName:exact dob:year
wrong date     : 0 record(s)
no evidence    : 3 record(s)
   precision=none       state=no_dob_age_year  conf=0.80 basis=lastName:exact firstName:exact dob:absent
   precision=none       state=no_dob_age_year  conf=0.80 basis=lastName:exact firstName:exact dob:absent
   precision=none       state=no_dob_age_year  conf=0.80 basis=lastName:exact firstName:exact dob:absent
exclusion, none: 1 record(s)
   precision=none       state=no_dob_age_year  conf=0.80 basis=lastName:exact firstName:exact dob:absent
```

*The dob object on a full-date record and on an age-only record*
```json
// Felony Record — the source publishes a full date
"dob": { "date": "1975-06-15", "birthYear": 1975, "birthMonth": 6, "age": 51, "ageAsOf": "2026-10-01", "dobPrecision": "full" }

// a jail roster that publishes only "AGE 34" at booking (production shape)
"dob": { "date": null, "birthYear": null, "birthMonth": null, "age": 34, "ageAsOf": "2026-09-30", "dobPrecision": "age" }
```

Against the second record, `dob: "1992-04-20"` checks whether a 34-year-old observed on 2026-09-30 could have been born on that date (`age_match`, `dob:ageFromDob`, with a ±1-year tolerance because an unpublished birthday is consistent with two birth years); `age: 34` checks the ages directly (`dob:age`); `birthYear: 1992` has nothing to compare and reads `no_dob_age_year`.

## Reading the result

| Query field | Against a `full` record | Against `year` / `month_year` | Against `age` | Against `none` |
| --- | --- | --- | --- | --- |
| `dob` | `dob_match` (`dob:match`) or dropped | `year_match` (`dob:year`, or `dob:monthYear` when both publish a month) or dropped | `age_match` (`dob:ageFromDob`, ±1 yr) or dropped | `no_dob_age_year` (`dob:absent`), kept |
| `birthYear` (+`birthMonth`) | `year_match` or dropped | `year_match` or dropped | `no_dob_age_year`, kept | `no_dob_age_year`, kept |
| `age` | `age_match` (`dob:age`, ±1 yr) or dropped | `age_match` (±2 yr) or dropped | `age_match` (±1 yr) or dropped | `no_dob_age_year`, kept |
| nothing | `no_dob_age_year` on every record |  |  |  |

"Dropped" means `dob_mismatch`: the record is removed before `records[]` is built and `counts.records` excludes it. `dob.date` is a full ISO date or `null` — never a stub — so you can hand it straight to a date parser.

## Edge cases and pitfalls

- **Send `birthYear` as a number.** A string `"1975"` is not type-checked; it compares unequal to every stored year and silently drops every dated record. The same applies to `age`.
- **`birthMonth` without `birthYear` is ignored.** It refines a year match to month+year only when both sides publish a month; a record with `dobPrecision: "year"` cannot be refined.
- **An invalid `dob` is `422 invalid_dob`**, not a no-match: the body must be `YYYY-MM-DD` (the first ten characters are parsed). Validate the form before sending.
- **A `year_match` is not a confirmation.** Two people with the same name and birth year in one county is not rare. Treat it as strong supporting evidence and require a reviewer for consequential outcomes.
- **Live checks can confirm what the record does not publish.** On a live search with a full `dob`, a record with no birth evidence may come back `matchState: "dob_match"` with `dobVerification: "source_confirmed"` — the source returned it for your date. `source_not_confirmed` keeps the record, ranked lower (`−0.15`). Cached searches never produce either value.
- **Placeholders are scrubbed.** `1900-01-01`, `0001-01-01`, `9999-*` and bare `1/1` dates collapse to `year` or `none`; they never produce a false `dob_match`.
- **A sandbox date must be exact.** Any other date is a contradiction and returns zero records, exactly as production would.

## Production checklist

- Capture a full date of birth wherever your intake allows; fall back to `birthYear`, then `age`.
- Type `birthYear`, `birthMonth` and `age` as integers before the request leaves your code.
- Show `dob.dobPrecision` next to `matchState` so a reviewer knows what the record could have confirmed.
- Treat zero records after sending a date as "no record with a consistent birth date", and log the date you sent.
- Re-run a name-only search with the date once you obtain it; the same records re-score.

## Related

- [Date of birth](https://offendersearch.app/docs/criminal/date-of-birth): the reference
- [Matching & confidence](https://offendersearch.app/docs/criminal/matching)
- [Handling false positives](https://offendersearch.app/docs/guides/criminal/false-positives)
- [The Record object](https://offendersearch.app/docs/criminal/record-object)
