# Date-of-birth matching

> A date of birth narrows the search and labels every record with how the registry's birth evidence agreed. Here is how to send it and how to read it.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/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

## When to use this

Whenever you hold a date of birth — from an ID scan, an application, an admissions record — send it. Registries differ in what they publish: a full date, a birth year only, a published age only, or nothing. The API compares your date against whichever of those the record carries and reports the outcome in `matchState`, so a reviewer knows exactly how strong the identity evidence is. You will build a search that sends `dob` (or `age` when that is all you have) and a reader that turns `matchState` and `dobPrecision` into a confidence tier.

## Before you start

- A test key on `https://sandbox.offendersearch.app`. Sandbox people: **Exact Match** publishes a full date, `1980-01-01`; the three **Name Only** registrants publish no date, year or age at all.
- `query.dob` is an ISO date, `YYYY-MM-DD`. `query.age` is an integer. They are alternatives; `dob` is stronger.
- Two tuning fields on `query`: `ageTolerance` (0–10, default 1) and `onAgeMismatch` (`drop` default, or `flag`).

> **The one thing people get wrong.** Sending a `dob` does **not** require the registry to publish one. A record with no birth evidence is still returned on the name, with `matchState: "no_dob_age_year"` and `unverified: true`. Dropping those records would hide real registrants; your job is to review them, not to filter them out.

## Step by step

1. **Send the date.** Search **Exact Match** with `dob` `1980-01-01`. The record comes back `matchState: "dob_match"`, `dobPrecision: "exact"`.
2. **Send a contradicting date.** Search **Exact Match** with `dob` `1990-05-05`. Zero records: a registry that publishes a full date that disagrees with yours rules the person out, and the record is filtered rather than shown.
3. **Send an age instead.** Search **Exact Match** with `age` 46. The record comes back `matchState: "age_match"`: the age derived from the published date falls within `ageTolerance` of yours.
4. **Search people with no birth evidence.** Search **Name Only** with any `dob`. All three come back `no_dob_age_year`, `unverified: true`, `dobPrecision: "unknown"`.

### Steps 1–3 — the same person, three ways

**cURL**
```bash
# 1. full date — matchState dob_match
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}}'

# 2. contradicting date — counts.records 0
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1990-05-05"}}'

# 3. age instead of a date — matchState age_match
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "age": 46}}'
```

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

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}

def search(query):
    r = requests.post(BASE + "/v1/search", headers=H, json={"query": query}, timeout=60)
    r.raise_for_status()
    return r.json()

for q in ({"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
          {"firstName": "Exact", "lastName": "Match", "dob": "1990-05-05"},
          {"firstName": "Exact", "lastName": "Match", "age": 46}):
    body = search(q)
    print(q, body["counts"]["records"], [r["matchState"] for r in body["records"]])
# ... 1 ['dob_match']
# ... 0 []
# ... 1 ['age_match']
```

**JavaScript**
```javascript
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };

async function search(query) {
  const res = await fetch(BASE + '/v1/search', { method: 'POST', headers: H, body: JSON.stringify({ query }) });
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return res.json();
}

for (const q of [
  { firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' },   // 1 record, dob_match
  { firstName: 'Exact', lastName: 'Match', dob: '1990-05-05' },   // 0 records
  { firstName: 'Exact', lastName: 'Match', age: 46 },             // 1 record, age_match
]) {
  const body = await search(q);
  console.log(q, body.counts.records, body.records.map((r) => r.matchState));
}
```

*Step 3 response record (trimmed)*
```json
{
  "recordId": "rec_28db9639d712c4c61e8e",
  "matchConfidence": 0.9,
  "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "name_match"],
  "matchDetail": { "strategies": { "lastName": "exact", "firstName": "exact" },
                   "fieldsPresent": ["lastName", "firstName", "dob"], "verification": "age_match" },
  "dob": "1980-01-01", "birthYear": 1980, "dobPrecision": "exact", "age": "46",
  "dobVerification": "age_match",
  "matchState": "age_match",
  "unverified": false
}
```

Note the bare `dob` token in `matchBasis`: it says the record **has** a date of birth, not how it agreed. The agreement is `matchState` (echoed as `matchDetail.verification`). `dobVerification` is the engine's raw token and is kept for compatibility; branch on `matchState`.

### Step 4 — no birth evidence on file

*Response record for Name Only with dob 1988-04-12 (trimmed)*
```json
{
  "recordId": "rec_ca91307a7124dca36777",
  "matchConfidence": 0.6,
  "matchBasis": ["lastName", "firstName", "unverified_no_dob_or_age", "lastName:exact", "firstName:exact", "name_match"],
  "matchDetail": { "strategies": { "lastName": "exact", "firstName": "exact" }, "fieldsPresent": ["lastName", "firstName"],
                   "signals": ["unverifiable", "matchedLegalName"], "verification": "unverified_no_dob_or_age" },
  "dob": null, "birthYear": null, "dobPrecision": "unknown", "age": "",
  "matchState": "no_dob_age_year",
  "unverified": true
}
```

### Tuning the age comparison

```json
{ "query": { "firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12",
             "ageTolerance": 2, "onAgeMismatch": "flag" } }
```

Eleven registries publish an age and no date. `ageTolerance` is how many years of slack that comparison allows (default 1, because a published age with an unpublished birthday is consistent with two birth years). `onAgeMismatch: "flag"` returns a name match whose published **age** contradicts your date as `matchState: "age_mismatch"`, always `unverified: true`, instead of dropping it — useful when "we checked and the age disagrees" is a fact you want to show. A contradicting full **date** is always filtered; the sandbox confirms this: **Exact Match** with `dob` `1990-05-05` and `onAgeMismatch: "flag"` still returns zero records.

## Reading the result

| `matchState` | The registry published… | Meaning for this job |
| --- | --- | --- |
| `dob_match` | a full date, equal to yours | Strongest confirmation. Lifts every name-strategy cap on the score. |
| `year_match` | a birth year only (some states), equal to your year | Good evidence, not a confirmed date. Raises the score floor to 0.80. Review. |
| `age_match` | an age (or a date you compared by age), within `ageTolerance` | Consistent, not confirmed. Review. |
| `no_dob_age_year` | nothing to compare | Name is the only evidence; `unverified: true`. Review against photo, address, age range. |
| `age_mismatch` | an age that contradicts your date | Only with `onAgeMismatch: "flag"`; always `unverified`. Usually noise. |
| `null` | — | You sent no `dob` or `age`. Not a failed check — nothing was checked. |

| Field | Values | Read it as |
| --- | --- | --- |
| `dobPrecision` | `exact`, `year`, `year_month`, `unknown` | How much birth date the source published. Never compare a `year` record as a full date. |
| `dob` | `YYYY-MM-DD` or `null` | Only a full date; `null` for year-only and age-only records. |
| `birthYear` | integer or `null` | Present whenever a year is known, including year-only records. |
| `age` | string, `""` when unknown | Published, or derived from an exact date. A string on the record; `query.age` is an integer. |
| `unverified` | boolean | `true` when your identifiers could not be checked against the record. |

## Edge cases and pitfalls

- **`"dob": "1980"` is a `422`** with a list-shaped `detail` (`loc: ["body", "query", "dob"]`). Send a full ISO date, or send `age` when the year is all you know.
- **A `dob_mismatch` record is never shown**; it is filtered. If a reviewer insists a person "should have come back", the usual reason is a wrong date on your side — re-run without `dob` and compare.
- **`matchState: null` is not `no_dob_age_year`.** `null` means you sent nothing to check; `no_dob_age_year` means you did and the registry had nothing. The sandbox shows both: **Exact Match** without `dob` is `null` at 0.9; **Name Only** with `dob` is `no_dob_age_year` at 0.6.
- **Truncation protects dated records.** When a query carrying a `dob` hits a registry's candidate cap (`incompleteReason: "truncated"`), records whose birth year matches are examined first; what gets cut is from the unknown-birth-date group.
- **Year-only states.** A registrant in a year-only registry can never be `dob_match`; `year_match` with `dobPrecision: "year"` is the best that registry can say. Do not hold it to the full-date tier.
- **Age drift.** A published age is anchored to the date the registry page was read, not today; that is what the default `ageTolerance` of 1 absorbs. Raise it for a high-recall pass and expect more same-name strangers.

## Production checklist

- `dob` sent whenever you hold one, as `YYYY-MM-DD`; `age` only as a fallback.
- Review tiers keyed on `matchState`; `dob_match` is the only tier eligible for an automatic path.
- `no_dob_age_year` records are reviewed, not dropped.
- `ageTolerance` and `onAgeMismatch` set deliberately and documented in your own runbook.

## Related

- [Searching by date of birth](https://offendersearch.app/docs/date-of-birth)
- [Matching and confidence](https://offendersearch.app/docs/guides/sex-offender/matching-and-confidence)
- [Understanding search results](https://offendersearch.app/docs/guides/sex-offender/understanding-results)
- [The Record object](https://offendersearch.app/docs/record-object)
