# Data model rules

> The reading rules that hold on every product, so one record format is enough to learn.

- **HTML:** https://offendersearch.app/docs/platform/data-model
- **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

## Normalized and original values

Every record is normalized into one schema per product: names split into parts, dates in ISO form, states as two-letter codes, enums from a closed vocabulary. The source's own text is kept beside it where it adds meaning — for example a charge keeps the source's `description` and `statute` verbatim while its `severity` is normalized — and a Sex Offender record carries its full per-state `stateData` block by default (`exclude: ["stateData"]` for a lighter payload), while `include: ["sourceData"]` (criminal) asks for more of the source's own fields.

## Provenance

Every record says where it came from: `sources[]` with the jurisdiction or public source code, a `recordUrl` to the source's own page where one exists, and `lastCheckedAt`. A record merged from several sources lists each. Nothing is presented without its source.

## Completeness

- `status: "complete"` means every source in scope was read to the end. `"partial"` means at least one did not finish; `counts.sourcesIncomplete` and `sourceStatus[]` say which and why (`incompleteReason`).
- An empty `records[]` is an answer only when `status` is `complete`. On `partial`, it is a lower bound.
- A record cap or a refused query is said explicitly (`capped`, `422 query_too_broad`); results are never silently truncated.

## Missing, null, empty, unknown

| You see | It means |
| --- | --- |
| Field absent | Not applicable to this record type or product, or not requested (an `include` option controls it). Never infer a value. |
| `null` | The field applies but the source did not publish it for this record. |
| `""` (empty string) | A string slot the source does not publish or left blank (Sex Offender: the registry does not publish that field; Criminal: the source gave no text). Different from `null`, which means unknown or not applicable — a typed absence (dates, numbers, nested objects, `matchState` on an unscored record). Render both as empty, but do not collapse one into the other when you store them. |
| `[]` (empty array) | Searched, and nothing found — for `records[]` only when `status` is `complete`. |
| `"unknown"` (an enum value) | The source did not say; for example a criminal `currentStatus` of `unknown`. |
| `notQueried` / `absent` in `matchBasis` | You did not send the field, or the record has none, so it was not scored. |
| An enum value you do not recognise | A value added after your integration. Treat as "other" and log it. |

## Dates and timestamps

- Timestamps are ISO 8601 with an offset: `2026-10-24T03:10:00+00:00`.
- Dates of birth carry a precision. Sex offender records: `dob` (ISO or `null`), `birthYear`, and `dobPrecision` ∈ `exact`, `year`, `year_month`, `unknown`. Criminal records: a `dob` object with `date`, `birthYear`, `birthMonth`, `age`, `ageAsOf` and `dobPrecision` ∈ `full`, `month_year`, `year`, `age`, `none`. Never compare a year-precision birth date as a full date.
- Dates inside criminal cases (`filedDate`, `offenseDate`, `dispositionDate`) are ISO where the source gave a parseable date and otherwise the source's own text; read [Understanding charges](/docs/guides/criminal/charges).
