# The Record object

> One normalized 76-field schema across 58 jurisdictions: identity, addresses, offense, stateData, flags, images and per-source provenance. Every field explained.

- **HTML:** https://offendersearch.app/docs/record-object
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## One schema across 58 jurisdictions

Every match in a search response is a normalized Record — a 76-field superset spanning the top-level record, `name`, `addresses[]`, `offenses[]`, the 34-field extensive `stateData` block, and per-source provenance. It is a strict superset of the legacy record shape plus scoring, verification, and freshness fields. The same schema is returned by `/v1/search` and by `/v1/records/{id}`, so one type definition covers the whole API.

## sources[] — which jurisdictions is this person on?

A person can appear on more than one registry: registered in one state, working in another, and listed federally as well. They come back as one record, not three — and `sources[]` names every jurisdiction that record was built from, one entry per jurisdiction, de-duplicated.

| Key | What it holds |
| --- | --- |
| `jurisdiction` | The jurisdiction’s two-letter code — the same vocabulary `query.state` and `jurisdictions` accept. |
| `registryName` | The registry’s own name, in full, as that jurisdiction publishes it. This is the name to show a user or print in a report — the specimen values in the payloads below are placeholders, not the strings any particular registry returns. |
| `recordUrl` | That jurisdiction’s own link to this registrant. `""` where the jurisdiction publishes no addressable page per registrant. |
| `scrapedAt` / `lastCheckedAt` | When this jurisdiction’s copy of the record was first recorded, and when it was last confirmed. |
| `sourceUpdatedAt` | The date the jurisdiction itself states it last changed the record; `null` where none is published. |

**`sources[].recordUrl`, paired with its `jurisdiction`, is the identifier to store.** It is the jurisdiction’s own permanent address for that person, it does not change when your query scope changes, and it is the link you would cite in an audit file. `recordId` is not that identifier — it is derived from the merge, so the same person can carry a different one on a state-scoped search and a nationwide search.

## Reading empty values

**No key is ever omitted.** Every top-level key is present on every record, on every endpoint, in every mode — so you test the value, never for key existence. There are three empty values and they mean different things:

| Value | Meaning |
| --- | --- |
| `""` | No value is held for this field on this record. That is a property of the jurisdiction — either it publishes no such field, or it publishes one and this registrant has none recorded. |
| `null` | Not known or not applicable; the precise meaning is per-field. On `flags.absconder` / `flags.predator` it means the jurisdiction does not publish it — which is not `false`. |
| `[]` | No items of this kind are held (no aliases, no addresses) — same reading as `""`, plus a third case on the array fields. |

Read `""` as “no value on file for this field”, never as a statement about the person. Jurisdictions differ in which fields they publish at all, so `marks: ""` means either that the jurisdiction publishes no such field, or that it publishes one and this registrant has none recorded.

**The array fields carry a third case: the jurisdiction does not publish that category for anybody.** `stateData.vehicles`, `stateData.professionalLicenses` and the non-residence entries of `addresses[]` return `[]` when the registrant genuinely has none *and* when that jurisdiction never publishes the category. Read `[]` as “none known”, never as “none exists”. Vehicles are published by 22 of 56 jurisdictions.

**The one exception — not requested rather than not published.** `stateData` (`null`) and `offenses` (`[]`) are omitted from the response unless you send `include: ["stateData"]`. Their empty values then say nothing about what the jurisdiction holds. `images` is **not** one of them — photos are returned on every response, so an empty `images` array does mean no photo is held.

## Types and formats to key your parser on

`age` is a **string**, not a number. `addresses[].type` is an **open vocabulary** — beyond `residence`, `employment` and `school` you will also see `last_known` (Minnesota) and `other`, so always have a default branch. Every date field is **ISO-8601**.

**Date format, as of the 2026-08-04 contract revision.** The four `offense` date fields and the five `stateData` ones are ISO-8601. A partial date stays partial — a jurisdiction that publishes only a month or a year gives you `"1998-11"` or `"1998"`, and the companion `datePrecision` object names the precision for every date key. Nothing is discarded: where a jurisdiction publishes something that is not a date, the field is `""` and its literal text is kept in `datesAsPublished`.

**`dob` shape, as of the 2026-08-06 contract revision.** `dob` is guaranteed to be a full `YYYY-MM-DD` date or `null`. Where a jurisdiction publishes only a birth year, `dob` is `null` and the year is in the integer field `birthYear`. If you have persisted `-01-01` birthdays sourced from this API, re-fetch those records: the ones whose `dobPrecision` is `"year"` now return `dob: null` with the year in `birthYear`, and the ones whose `dobPrecision` is `"exact"` are real 1 January birthdays and are unchanged.

There is no date filter on the `offense` or `stateData` dates — `dob` is the one date you can narrow a query on, alongside the `createdAt*` / `updatedAt*` provenance ranges.

## Field reference

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recordId` | `string` | optional | THE HANDLE FOR THIS RESULT — NOT A STABLE PRIMARY KEY. DO NOT STORE IT AS ONE. It addresses GET /v1/records/{recordId} and it is deterministic for a given merge, but it is DERIVED FROM THE MERGE: it is computed from the set of registry rows that were combined into this person on THIS query, so it changes when the query scope changes and it can change between releases. The same man searched with state: "KY" and searched nationally is one person and two recordIds, because the national search merges in a second registry's row for him and the merged identity is a different identity. Keying a diff on recordId therefore reports people as MISSING who were returned both times, under a different id. THE STABLE KEY IS source.recordUrl / sources[].recordUrl — the registry's own permanent URL for that person on its own site. Store that. Match on it. It does not move with query scope, it does not move with a release, and it is the identifier the registry itself will still honour next year. TWO LIMITS ON THAT, BOTH PROPERTIES OF THE REGISTRY, AND YOU SHOULD HANDLE BOTH: recordUrl is "" where the jurisdiction publishes no addressable page per registrant; and on Florida, California, Georgia, Missouri and Oregon (partly on Oklahoma, Maine, Virginia and Arizona) it is the registry's own search or landing page rather than that registrant's page — a valid citation, but shared by many people, so it is not a key on its own. For both, key on jurisdiction + name + dob + registrationState instead. Read sources[] rather than source alone, because the person may be corroborated by a registry whose URL IS per-registrant even when the primary one is not. |
| `uuid` | `string` | optional | The REGISTRY's own id for this person (parity with a legacy uuid / personUuid) — not ours. "" when the registry exposes no id. Do not confuse with recordId. |
| `recordType` | `enum` | optional | sex_offender today; additive as new types ship. |
| `matchConfidence` | `number` | optional | Our score from 0 to 1. It measures HOW MUCH IDENTITY EVIDENCE WE HOLD, not the probability that this is your person — 0.4 base, +0.3 for a DOB, +0.2 for a full name, +0.1 for corroboration by more than one registry. A widened match is then capped: nickname 0.75, initial/prefix/fuzzy/middle 0.60, alias-only 0.55 (the cap is skipped when you supplied a DOB and the record's full DOB matched). Filter on matchState, not on this. |
| `matchBasis` | `string[]` | optional | MIXES TWO VOCABULARIES. Bare tokens (lastName, firstName, dob) mean only "the record HAS this field populated" — they are NOT match reasons. Qualified tokens (field:strategy) are the real reasons: lastName:exact, lastName:prefix, lastName:fuzzy, firstName:exact/nickname/initial/prefix/fuzzy, firstName:middle (the given name you sent is the person's MIDDLE name), firstName:absent (the record has no first name, so your query could not be disproved), alias:exact/prefix/fuzzy, plus name_match, alias_match, middle_name_match and unverified_no_dob_or_age. Switch only on the qualified tokens and on matchedName.type. |
| `matchedName` | `object \| null` | optional | Which name actually matched your query: { value, type } where type is "legal" (the registered name) or "alias". null when you searched without a name. This is how you tell an alias hit from a registered-name hit at a glance. |
| `name` | `Name` | optional | first, middle, last, suffix, and a pre-formatted full. |
| `aliases` | `string[]` | optional | Known alternate names and spellings, as the registry publishes them — mixed shapes ("SURNAME, GIVEN" and "Given Surname" both occur in one response), so never parse positionally. [] carries two facts and the response does not label which: either the registry publishes no alias field, or it publishes one and this person has none on file. Both are properties of the source, and neither is evidence that the person uses no other name. How often a registry publishes aliases is its own decision and the spread is wide — KY 99%, AK/NE 89%, ID 46%, SD 44%, and WV 2% because the West Virginia registry publishes no alias field and the only alternate names it publishes appear within the offence narrative. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §5. |
| `nicknames` | `string[]` | optional | Output-only, DERIVED BY US — never sourced from the registry, so never present it as data on file. Nickname/variant expansion of name.first, for name-search recall: "Robert" → ["Bob","Bobby","Rob","Robbie","Robby"], "John" → ["Jack","Johnnie","Johnny","Jon"], "Zebediah" → []. Parity with a legacy firstName_nicknames field. |
| `dob` | `date \| null` | optional | A FULL ISO-8601 CALENDAR DATE (YYYY-MM-DD) OR null — never anything else. Never a bare year, never a partial date, and never a month and day we chose for you. It is safe to hand straight to a date parser, and null is the only other value it can take. null means we cannot give you a complete date for this person, which happens two ways, and dobPrecision tells you which: the registry publishes only a birth YEAR (dobPrecision "year" — the year is in birthYear), or it publishes nothing finer than an age (dobPrecision "unknown" — read age). ★ A null dob DOES NOT COST YOU THE RECORD ON A DOB SEARCH. A year-only record still matches any date you query inside that year and comes back as matchState "year_match"; an age-only record still matches a date consistent with its published age and comes back as "age_match". AZ, MD and WI publish an age and no date on any surface — that null is final. California publishes an exact date of birth for effectively every registrant, so a rare California null is an individual gap and not a statement about what the state publishes. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §6.1. |
| `birthYear` | `integer \| null` | optional | THE YEAR THE REGISTRANT WAS BORN, AS AN INTEGER (1961), WHENEVER WE KNOW IT. Read this — not dob — for anything that reasons about a year, so your code never has to branch on which kind of record it is holding. It is populated in both directions: when the registry published a full date, birthYear is that date's year and dob carries the date; when the registry published only a year, birthYear carries the year and dob is null. null means the birth year genuinely is not known to us. IT IS NEVER INFERRED FROM AN AGE, and that is deliberate: an age is not a date, deriving a year from one would bake in the day we read the page and would be wrong the moment the person has a birthday, and this field is served as fact rather than as an estimate. So an age-only record carries age and birthYear null — and it is still reachable by a name + dob search, because that widening happens when we match your query rather than when we store the record, and it is labelled matchState "age_match" so you can see it happened. To distinguish a year the registry PUBLISHED from a year we read off a published date, read dobPrecision, not the presence of dob. |
| `dobPrecision` | `enum` | optional | HOW MUCH OF THE BIRTH DATE THE REGISTRY ACTUALLY PUBLISHED: exact · year · unknown. "exact" means the registry published a full calendar date and dob carries it. "year" means the registry publishes ONLY a birth year (MA, GA, ND, NV, PA, and sometimes KS): dob is null — we do not manufacture a month and a day to fill it — and the year is in birthYear. "unknown" means dob and birthYear are both null and the registry published nothing finer than an age, if that; read age. READ THIS FIELD RATHER THAN INSPECTING dob: it is the authoritative statement of what the source published, and it is what separates a year the registry printed from a year we read off a full date it printed. ("year_month" is reserved in the contract; no registry emits it.) The four date fields inside offense, and the five inside stateData, carry their own separate datePrecision object — dobPrecision governs the date of birth only. |
| `age` | `string` | optional | Age in years as a STRING ("58"), not a number. "" when not published. Many registries publish age instead of a DOB. |
| `sex / race / ethnicity` | `string` | optional | Demographics as the registry publishes them — free text, NOT normalized ("Male", "M", "W"). "" when we hold no value. READ race AND ethnicity TOGETHER: several registries, California among them, publish ONE column covering both concepts, and we route Hispanic values to ethnicity and everything else to race — so for those states the two keys are mutually exclusive and reading race alone silently drops every Hispanic registrant. California: race 51%, ethnicity 48%, either 99.4%. Compute coverage on (race \|\| ethnicity). California's race vocabulary is the state's own and unusually fine-grained (17 values, including "Filipino", "Samoan", "Guamanian"), so do not map it onto a five-bucket scheme without deciding what each becomes. MAINE AND THE DISTRICT OF COLUMBIA NEED ONE SENTENCE OF THEIR OWN. Neither jurisdiction publishes a sex or a race. Their sex comes from the jurisdiction's OWN federal (NSOPW) feed, which is why it reads "M"/"F"/"U" rather than "Male"/"Female", and it is on nearly every ME/DC record. Their race is "" on every record and will stay that way: neither jurisdiction publishes it, and there is no ethnicity to fall back to — so a ME/DC record with a sex and an empty race is final. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §6.3. |
| `height / weight / eyeColor / hairColor` | `string` | optional | Physical description as published. No unit is attached and formats vary wildly ("5'09\"", "509", "165lbs"). "" when not published. |
| `marks` | `string` | optional | Scars, marks and tattoos as recorded by the registry — free text, no vocabulary, no fixed separator, often several hundred characters ("Tattooed Arm, left upper; Scar on Chest; Pierced ear, left"). "" carries two facts and the response does not label which: either the registry publishes no such field at all, or it publishes one and this person has none on file. Both are properties of the source, and neither is evidence that a person has no tattoos. NOT PUBLISHED by KY, NE, WV, ID, SD, AK, TX, IL, VA, GA, MA and ME. Maine is the statutory case: its registry publishes NO physical description at all — no marks, height, weight, eye or hair colour — because state law releases those only against a written request naming the requester. Around 28 other states do publish the field, each at its own rate — MO 85% down to MT 22%. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §6.5. |
| `addresses` | `Address[]` | optional | Each with type, line1, city, county, state, zipcode, and nullable lat/lng (null = not geocoded, which is not the same as no address). type is an OPEN vocabulary: residence · employment · school · last_known (MN) · other. [] when no address is published. line1 IS NOT ALWAYS A STREET: the District of Columbia publishes block-level addresses by design ("2100 BLOCK OF NEW HAMPSHIRE AVENUE NW") and has no house numbers on any surface, and employment/school rows in several states (Maine among them) carry the employer name folded in ahead of the street ("KENT PACKERS, 51 KENT RD") because the registry publishes the name too and there is no separate key for it — split on the first comma if you need the street alone. Maine is a special case of its own: it publishes a residence street only for registrants inside its tier scheme, and the town and nothing finer for everyone else, so the majority of Maine records carry city + state with an empty line1 and no request will produce more. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §7. |
| `registrationState` | `string` | optional | WHICH REGISTRY HOLDS THIS PERSON — the registering state or territory. It is NOT where they live, and there is deliberately no single field called "state", because the two answers disagree constantly: of the 846,485 records held by a two-letter state registry, 105,028 (12.4%) have NO address in the state whose registry holds them, and 8,564 more carry both a home-state and an out-of-state address. 36,525 of Florida's 92,690 registrants have no Florida address on file. The value is in exactly the vocabulary query.state accepts, so you can send it straight back as a filter and find this record again. "" ON 35,153 RECORDS (2.34%) AND THAT IS NOT AN ERROR: 29,097 come from NSOPW where the federal feed named no member registry, and 6,056 come from 103 TRIBAL registries — a tribe is its own registering authority and we do not map one onto the state it sits inside, because that would assert a containment we have not verified. source.jurisdiction is populated on 100% of records and carries the registry's own code in those cases. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §7.5. |
| `addressStates` | `string[]` | optional | WHERE THEIR ADDRESSES ARE — every distinct state appearing in addresses[], first-seen order, de-duplicated. THE PLURAL IS LOAD-BEARING: 23,058 records carry addresses in two or more states (22,626 in two, 432 in three or more), because residence, employment and school addresses are published independently by the registry and need not agree. The array does NOT say which kind of address contributed each state — addressStates[0] is not "where they live", exactly as addresses[0] is not. Read addresses[] and filter on type when you need the residence specifically. [] MEANS NO ADDRESS ON FILE CARRIES A STATE — 70,973 records (4.72%). It does not mean the person has no address and it does not mean we did not look: the registry published none we could parse. Those records are still returned by a state search, via registrationState, which is the only thing that reaches them. Values are canonicalised like registrationState, with one exception: a token we do not recognise is passed through upper-cased rather than dropped, because registries publish real non-state codes here — Florida writes "YY" for out-of-country on 5,005 addresses. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §7.5. |
| `offense` | `Offense` | optional | Primary offense — ALWAYS an object, never null; all 16 fields are "" (or null for federal) when nothing is published. Survives a light call, unlike offenses[]. Its four date fields (convictionDate, offenseDate, registrationDate, releaseDate) are ISO-8601 — BREAKING CHANGE 2026-08-04: they used to be the registry's own string passed through, so one response could carry "2003-03-31", "10/11/1988" and "Aug. 10, 1987" in the same key (offenseDate was ISO on only 45.4% of populated values). A PARTIAL DATE STAYS PARTIAL: a registry publishing only a month or a year yields "1998-11" or "1998" and we never invent a day — read the companion datePrecision object (exact \| year_month \| year \| none \| unparseable), one entry per date key, always present. NOTHING IS DISCARDED: a value that is not a date at all comes back "" with precision "unparseable" and the registry's literal text preserved in datesAsPublished, which also carries the original for any date we reformatted. offenseDate is WHEN THE CRIME HAPPENED and is a different fact from convictionDate — the two can be years apart, so never substitute one for the other (TN publishes only the offence date and no conviction date at all). convictionCounty/convictionCity are the COURT's county and city, not where the person lives — jurisdiction still carries the conviction STATE. convictionCount is how many convictions that one offence ROW stands for (MA prints it as "No. of Convictions"): "" does not mean one. federal is THREE-STATE — true/false when the registry publishes a federal-vs-state column (Oklahoma only today), null when it does not, so never coerce it with a falsy check. ★ riskLevel AND tier ARE RISK CLASSIFICATIONS AND NOTHING ELSE — the registry's assessment of the danger the person is judged to present ("Sexually Violent Predator", "Level 3", "Tier II"), in each state's own vocabulary, with no cross-state scale. THEY DO NOT CARRY REGISTRATION STATUS. Whether someone is currently confined, absconded, deported, deceased or living in the community is a different question with a different answer, and it is stateData.status — which needs include: ["stateData"]. Florida is the state where this matters most, because FDLE publishes a status on every registrant and no risk level at all: on a Florida record riskLevel is "" and the words you want ("Confinement", "Released - Subject to Registration", "Absconded", "Deceased", …) are in stateData.status. A "" riskLevel means the registry publishes no risk classification, never that the person is low risk. THERE IS NO DATE FILTER ON THESE FIELDS: dob is the one date you can narrow a query on. |
| `offenses` | `Offense[]` | optional | The full offense list. [] UNLESS you send include: ["stateData"] — an empty array on a light call says nothing about what the registry holds. |
| `stateData` | `StateData \| null` | optional | The full 34-field extensive superset: stateOffenderId, status, designation, registrationStarts, registrationEnds, sentenceCompletionDate, verificationRequirement, lawAgency, judgmentOfConvictionUrl, vehicles[], photos[], professionalLicenses[], shoeSize, shoeWidth, build, complianceStatus, isLifetimeRegistration, lastVerificationDate, addressVerificationDate, incarcerationStatus, comments, criminalHistory, adjudication, registrationDuration, skinTone, residencyRestriction, employmentRestriction, exclusionZones[], district, psa, quadrant, birthCity, birthState, birthCountry. null UNLESS you send include: ["stateData"] — a null here on a light call says nothing about what the registry holds. HOW TO GET IT: add "include": ["stateData"] alongside "query" in the POST body ({"query": {…}, "include": ["stateData"]}); it is a per-request parameter available on every key, it brings offenses[] with it, and it is the only way this object is populated. ★ status IS THE FIELD MOST PEOPLE ARE LOOKING FOR AND IT LIVES ONLY HERE: where the registrant stands with the registry, in the registry's own words, and the answer to "is this person still at liberty?". It is free text and each state has its own vocabulary, so match on the exact strings the state you care about publishes — there is no cross-state enum. Florida (FDLE) publishes nine: "Released - Subject to Registration", "Confinement", "Supervised - FL Dept of Corrections", "Supervised - US Probation", "Supervised - FL Dept of Juvenile Justice", "Deported", "Deceased", "Absconded", "Civil Commitment"; other states use their own ("Compliant", "Non-Compliant", …). Two of those conditions also have fields you can read without string matching: flags.absconder, and incarcerationStatus where the registry publishes it. status is NOT a risk level — see offense.riskLevel. MANY OF THESE ARE SINGLE-STATE and are ""/[] everywhere else, always: skinTone/residencyRestriction/employmentRestriction/exclusionZones are IA-only, district/psa/quadrant are DC-only, professionalLicenses is UT-only, birthCity/birthState are WY-only (Guam fills birthCountry), and registrationStarts/sentenceCompletionDate are OK-only, shoeSize/shoeWidth are TX-only, and build is MN-only. Its five date fields (registrationEnds, lastVerificationDate, addressVerificationDate, sentenceCompletionDate, registrationStarts) are ISO-8601 as of 2026-08-04 — a BREAKING change, and 100% of sentenceCompletionDate/registrationStarts values changed shape because Oklahoma publishes MM-DD-YYYY. They carry the same datePrecision and datesAsPublished companions as offense. WATCH registrationEnds SPECIFICALLY: 32,569 records publish a DURATION there rather than a date — Wisconsin "15 Years"/"Life" (25,218), Oklahoma "Lifetime" (6,609), North Dakota "LIFETIME" (742) — so those come back "" with precision "unparseable" and the word kept in datesAsPublished. An empty registrationEnds on a WI/OK/ND record is NOT "no end date on file"; check datePrecision, then isLifetimeRegistration and registrationDuration. sentenceCompletionDate is the date the SENTENCE completes and is NOT registrationEnds (the registration term, often decades later or "Lifetime") and NOT offense.releaseDate (release from custody); Oklahoma prints future dates there for people still serving, so it is a scheduled/recorded completion date, not proof a sentence was served. |
| `flags` | `object` | optional | THREE-STATE: { absconder, predator } are each true, false, or null. true = the registry affirmatively reports the designation; false = it affirmatively reports the person does NOT have it; null = the registry does not publish it at all. null is NOT false — a falsy check silently merges "confirmed not an absconder" with "we have no idea". |
| `images` | `object[]` | optional | Photo URLs ([{ url }]) served by the publishing jurisdiction. ALWAYS RETURNED: photos are not behind include, and [] genuinely means we hold no photo for this person. images[0] is the CURRENT photo and anything after it is photo HISTORY, newest first. THE ARRAY LENGTH VARIES BY STATE and is not fixed: Oklahoma records carried exactly one element until 2026-08-04 and now carry up to five, and a record merged across two registries carries both registries' photos. Never assume one photo per record. |
| `source` | `SourceRef` | optional | The PRIMARY source (the registry whose data won on merge): jurisdiction, registryName, recordUrl, scrapedAt, lastCheckedAt, sourceUpdatedAt. sourceUpdatedAt is null on most registries — few publish a last-updated date. |
| `sources` | `SourceRef[]` | optional | EVERY REGISTRY THIS PERSON APPEARS ON, in one array, deduplicated so a registry appears once — each entry carrying that registry's name (registryName), its two-letter code (jurisdiction) and its own link to this registrant (recordUrl), plus scrapedAt / lastCheckedAt / sourceUpdatedAt. This is how you answer "which registries is this person on?" without issuing one query per state: a man registered in South Carolina who works in Texas comes back as ONE record whose sources[] names the South Carolina registry, the Texas DPS registry and NSOPW, each with its own link. More than one entry is genuine cross-registry corroboration and it raises matchConfidence. source (singular) is simply whichever of these won the merge and supplied the top-level values; it is always also present in sources[], so read sources[] when you want the whole picture and source when you want the one row the record was built from. ★ sources[].recordUrl — WITH its jurisdiction — IS THE DURABLE KEY FOR A PERSON ON A REGISTRY. It is the registry's own permanent address for them, it does not move when your query scope changes, and it is what you should store and diff on. recordId must not be used for that; see recordId above. |
| `matchState` | `enum \| null` | optional | HOW THIS RECORD WAS CHECKED AGAINST THE DOB OR AGE YOU SUPPLIED — the field to branch your confidence on, rather than matchConfidence. dob_match: the registry holds a full date of birth and it equals yours (the strongest confirmation this API can give). year_match: the registry publishes only a birth YEAR and that year equals your date's year — the day and month were never checked because the source never published them. age_match: no date and no year is published, but the registry's age agrees with the age your date implies, within a year for birthday drift — weaker, and roughly one person in a hundred of that age will coincide. no_dob_age_year: the registry published no date, no year and no age, so nothing could be checked; the record is returned because the NAME matched, and unverified is true. age_mismatch: the record matched on NAME, publishes only an age, and that age CONTRADICTS the date you sent — returned only if you ask for it with onAgeMismatch: "flag", and always unverified, so it can never be read as a confirmed identification. dob_mismatch: the record had a date, year or age and it DISAGREED with yours — these are filtered OUT of records[], so you will not normally see this value; it is documented so you know a true conflict is excluded rather than silently shown. null: you did not supply a dob or an age, so there was nothing to verify against and the value is not meaningful. See Searching by date of birth. |
| `dobVerification` | `enum` | optional | The raw match-state token (backward-compat). matchState is the clean, distinct field to read; see Searching by date of birth. |
| `unverified` | `boolean` | optional | True when the record had no DOB and no age to verify against — shown anyway, flagged. |

## Example record

```json
{
  "recordId": "rec_717a89b01deda9457ced",
  "uuid": "0000000",
  "recordType": "sex_offender",
  "matchConfidence": 1.0,
  "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "name_match"],
  "matchedName": { "value": "EXAMPLE PLACEHOLDER SURNAME", "type": "legal" },
  "name": {
    "first": "EXAMPLE", "middle": "PLACEHOLDER", "last": "SURNAME",
    "suffix": "", "full": "EXAMPLE PLACEHOLDER SURNAME"
  },
  "aliases": [],
  "nicknames": [],
  "dob": "1968-02-09",
  "birthYear": 1968,
  "dobPrecision": "exact",
  "age": "58",
  "sex": "M",
  "race": "White",
  "ethnicity": "",
  "height": "5'10''",
  "weight": "165lbs",
  "eyeColor": "Hazel",
  "hairColor": "Brown",
  "marks": "Tattoo on L_arm , Scar on (Leg Right)",
  "addresses": [
    {
      "type": "residence", "line1": "1 EXAMPLE DR", "city": "MIDDLETOWN",
      "county": "Butler", "state": "OH", "zipcode": "45042",
      "lat": null, "lng": null
    }
  ],
  "offense": {
    "crime": "Rape",
    "statute": "2907.02",
    "riskLevel": "(Pre AWA) Sexual Predator",
    "tier": "(Pre AWA) Sexual Predator",
    "convictionDate": "1988-10-11",
    "registrationDate": "",
    "releaseDate": "1999-11-18",
    "caseNumber": "",
    "victimAge": "Juvenile",
    "victimSex": "Male",
    "jurisdiction": "OH",
    "offenseDate": "",
    "datePrecision": {
      "convictionDate": "exact",
      "offenseDate": "none",
      "registrationDate": "none",
      "releaseDate": "exact"
    },
    "datesAsPublished": {
      "convictionDate": "10/11/1988",
      "releaseDate": "11/18/1999"
    },
    "convictionCounty": "",
    "convictionCity": "",
    "convictionCount": "",
    "federal": null
  },
  "offenses": [],
  "stateData": null,
  "flags": { "absconder": null, "predator": true },
  "images": [],
  "source": {
    "jurisdiction": "OH",
    "registryName": "State Sex Offender Registry",
    "recordUrl": "https://…",
    "scrapedAt": "2026-08-02T00:00:00Z",
    "lastCheckedAt": "2026-08-02T00:00:00Z",
    "sourceUpdatedAt": null
  },
  "sources": [
    {
      "jurisdiction": "OH",
      "registryName": "State Sex Offender Registry",
      "recordUrl": "https://…",
      "scrapedAt": "2026-08-02T00:00:00Z",
      "lastCheckedAt": "2026-08-02T00:00:00Z",
      "sourceUpdatedAt": null
    }
  ],
  "dobVerification": "",
  "matchState": null,
  "unverified": false
}
```

What to notice: `ethnicity`, `registrationDate` and `caseNumber` are `""` — Ohio does not publish them. `federal` is `null`, meaning Ohio publishes no federal-vs-state column; only Oklahoma does. `flags.absconder` is `null` (Ohio does not publish it) while `flags.predator` is `true`. `stateData` and `offenses` are empty only because this call omitted `include`; `images` is empty because this record has no photo — `images` is never withheld. `matchState` is `null` because the query supplied no `dob` or `age`. And `birthYear` is `1968` even though the year was never published on its own — it is the year of the full date, so year logic reads one field whatever the jurisdiction published.

## GET /v1/records/{recordId} — Get a record

Fetch a single normalized record by id — for re-displaying or refreshing a specific person you already found.

**Authentication:** `X-API-Key` header.

Pass **either** identifier a search published for the person: our `recordId` (`rec_…`) or the registry’s own `uuid`. This is our equivalent of a legacy `uuid` / `personUuid` lookup. Returns the full Record object, including every source that corroborated the person.

Both identifiers resolve against the same durable index, so a lookup is deterministic: the id a search published for a person resolves to that person for as long as the identifier is current.

**A `404` means the identifier is not current — never that the person is unregistered.** A `uuid` belongs to the publishing jurisdiction, and jurisdictions re-issue their own identifiers, sometimes for an entire registry at once; the registrant is then published under a new `uuid`. Re-search by name and state and store the id the new search returns. Treat a `uuid` as a cache key to refresh, not as a primary key.

**Contract revision 2026-08-04 — `recordId` is namespaced by jurisdiction.** `recordId` is derived from jurisdiction + `uuid`, so it is unique across jurisdictions and remains deterministic for a given merge. Values issued before that revision are superseded: re-run your searches and key on `sources[].recordUrl` with its `jurisdiction`, which is the durable identifier.

**A `uuid` is unique only together with its jurisdiction, so this endpoint can return `409`.** It is the jurisdiction’s own identifier, and jurisdictions assign them independently — `20059` identifies a Florida registrant, a Pennsylvania one *and* a Wisconsin one; 55,893 identifiers are shared across jurisdictions, covering 147,624 records. This endpoint returns a single record and does not disambiguate on your behalf: an explicit `409` is the correct contract when an identifier is ambiguous. Use `GET /v1/compat/sexoffender?uuid=…&state=XX`, which returns every match with its jurisdiction, or use `recordId`, which is namespaced per jurisdiction and never collides.

### Path parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recordId` | `string` | required | The recordId (rec_…) or the registry uuid from a search result. Both resolve. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/records/rec_4b1e \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

**Node**

```javascript
const res = await fetch(
  "https://api.offendersearch.app/v1/records/rec_4b1e",
  { headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY } },
);
const record = await res.json();
console.log(record.name.full, record.source.jurisdiction);
```

**Python**

```python
import os, requests

resp = requests.get(
    "https://api.offendersearch.app/v1/records/rec_4b1e",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
)
record = resp.json()
print(record["name"]["full"], record["source"]["jurisdiction"])
```

### Response

```json
{
  "recordId": "rec_4b1e",
  "uuid": "b1e4-…",
  "recordType": "sex_offender",
  "name": { "first": "John", "middle": "A", "last": "Doe", "full": "John A. Doe" },
  "dob": "1980-04-12",
  "dobPrecision": "exact",
  "dobVerification": "dob_match",
  "unverified": false,
  "source": {
    "jurisdiction": "NJ",
    "registryName": "State Sex Offender Registry",
    "recordUrl": "https://…",
    "lastCheckedAt": "2026-07-25T09:14:00Z"
  }
}
```

404 when no CURRENT record carries this identifier — re-search by name and state, because the registry may have re-issued it. 409 when the identifier matches records in more than one jurisdiction; the detail names them, and you disambiguate with GET /v1/compat/sexoffender?uuid=…&state=XX or by using the recordId.

---

## Related

- Previous: [Batch & CSV search](https://offendersearch.app/docs/batch.md)
- Next: [Verification reports](https://offendersearch.app/docs/reports.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
