Fetch a single normalized record by recordId or uuid
Resolves a record by EITHER identifier a search published for it: our recordId (rec_…) or the registry's own uuid. DATABASE-BACKED since 2026-08-04 — it previously read only an in-memory index local to one API container, keyed on recordId alone, so a uuid never resolved and a recordId resolved only when the follow-up request happened to reach the same container as the search.
A 404 means THIS IDENTIFIER IS NOT CURRENT — never that the person is unregistered. Registries re-issue their ids (KY, MO, OR, AZ, IA and WV each re-issued their entire registry within eight days to 2026-08-04; CA re-issued ~104,000). Re-search by name and state, then store the new uuid.
An identifier carrying a byte no id of ours can hold — a NUL or any other control character — is also a 404. Until 2026-08-05 /v1/records/%00 and /v1/records/a%00b returned 500, the only 5xx reachable from ~60 hostile inputs, because the NUL reached a database parameter. A 5xx is a claim that WE failed, and for a malformed id that claim is false. %01 and %25 always answered 404; now every impossible id answers the same way.
A 409 means the id is AMBIGUOUS. A uuid is unique only together with its jurisdiction — 20059 identifies a Florida, a Pennsylvania and a Wisconsin registrant, and 55,893 ids are shared across jurisdictions (147,624 records). This endpoint returns one record and will not guess which you meant; use GET /v1/compat/sexoffender?uuid=…&state=XX, which returns every match with its jurisdiction, or use recordId, which is namespaced per jurisdiction and does not collide.
Request
- Base URL: https://api.offendersearch.app
- URL: https://api.offendersearch.app/v1/records/{recordId}
- Auth: API key in header X-API-Key
Path parameters
The recordId (rec_…) or the registry uuid from a search result.
Response
The record
Our per-record id, rec_ + 20 hex. Hashed from JURISDICTION + the registry uuid (or, when the registry publishes no uuid, jurisdiction + record url + full name + dob). *** NOT A STABLE KEY FOR A PERSON. *** recordId is derived from the MERGE: when we find one person in several registries we merge those rows and one WINS, and recordId (like uuid) describes the winner. Which row wins depends on which registries were in YOUR search, so narrowing the scope changes the id for the same human with nothing re-ingested. Verified on production 2026-08-05, one man (Stephen Vincent Allen, dob 1961-09-25, TX Sid=07428710 and SC Id=583650): nationwide -> rec_6d066763131ebe8c77ea (uuid 07428710); jurisdictions:["SC"] -> rec_b0b4abc2433b71400670 (uuid 583650); jurisdictions:["NSOPW"] -> rec_f8c63c0ec62e7563bd6c. One person, four ids, no defect. CONSEQUENCE: collecting ids from state-scoped queries and then checking them against a nationwide result reports a correct merge as a MISSING RECORD. Compare results as PEOPLE (name + dob + registrationState, or the sources[] entries), never as id sets across different scopes. DETERMINISTIC WITHIN ONE SCOPE: re-run the same request and you get the same ids. Store the scope with the id. BREAKING 2026-08-04: the jurisdiction was added, so every recordId for a record carrying a uuid changed once. It previously hashed the uuid ALONE, which collapsed registrants that different registries had given the same id onto ONE recordId - source id 20059 returned rec_a82c17793c3bd390eef1 for both a Pennsylvania and a Wisconsin registrant. 147,624 records were affected. Stored ids from before that date will not match; re-key.
The REGISTRY's own id, verbatim; "" (18%) when that registry publishes none. Unique only WITH its jurisdiction — 55,893 ids are shared across jurisdictions (147,624 records), so 20059 is a Florida, a Pennsylvania AND a Wisconsin registrant. Registries also RE-ISSUE ids in bulk, which retires the old value: treat a stored uuid as a cache to refresh, not as a primary key. It is the PRIMARY registry's id for the assembled record, so like recordId it changes when the registries covered by your search change. It is not the more stable of the two - see recordId above for which identifier to store.
OUR relevance score for this match (offenders.io has none). Null when unscored. CAPPED by how the name matched, so a widened match is never reported as certainty: exact on the registered name = no cap; nickname <= 0.75; prefix / fuzzy / middle name / no first name published <= 0.60; alias-only <= 0.55. The cap is lifted only when you supplied a dob and the record's full DOB matched it. A partial-name hit is a CANDIDATE match, never identity verification.
Why the record is in the result set, PER FIELD, so a widened search is never silent. Name tokens are <field>:<how>: lastName:exact / firstName:exact (the field equals what you sent), lastName:prefix / firstName:prefix (the field STARTS WITH what you sent), firstName:nickname (a nickname equivalence, e.g. bob -> Robert), lastName:fuzzy / firstName:fuzzy (spelling variant or typo), firstName:middle (the given name you sent is the person's MIDDLE name, matched as a whole token of the registered legal name), firstName:absent (the record publishes no first name, so it could not be disproved — kept and flagged), and alias:exact / alias:prefix (an ALIAS matched, not the registered name). Also carries the legacy lastName/ firstName/dob tokens for which identity fields the record itself provides, plus name_match, alias_match, middle_name_match, or unverified_no_dob_or_age. Example: ["lastName:prefix","firstName:nickname","name_match"]. Parsing note — this array mixes two vocabularies. The bare lastName / firstName / dob tokens describe which identity fields the RECORD populates; they do NOT mean that field matched your query and they appear even when you did not search on it (a record can carry the dob token on a search with no dob). To read match quality reliably, use only the tokens containing a colon, plus name_match, alias_match and unverified_no_dob_or_age. Prefer matchDetail, which is this same information already split into its separate vocabularies.
Known aliases as the registry publishes them (flat strings, mixed shapes — "SURNAME, GIVEN" and "Given Surname" both occur; do not parse positionally). IMPORTANT: [] is TWO different facts and this response cannot tell them apart — either the registry publishes no alias field, or it does and this person has none on file. 69% of records carry at least one, but the rate is a property of the source: KY 99%, AK/NE 89%, ID 46%, SD 44%, and WV 2% because the WV registry has no alias field at all and the only names it publishes sit inside the offence narrative. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md section 5.
OUTPUT-ONLY. First-name nickname/variant expansion, computed at serialize-time from name.first for name-search recall (parity with offenders.io firstName_nicknames; e.g. "Jonathan" -> ["john","nathan"]). Computed at serialize-time and never populated from source data. Empty when nothing was expanded.
Date of birth — a full ISO-8601 YYYY-MM-DD date, or null. Never anything else. If this field has a value, a registry printed a complete date; you can hand it straight to a date parser.
- "1964-08-13" — a full date the registry printed. dobPrecision: exact.
- null — no full date was published. The record may still tell you a great deal about when this person was born: read birthYear (present whenever the year is known) and age (published by many registries that publish no date at all). dobPrecision says which.
★ You never have to compensate for a null here when you search. A dob you send is matched against every kind of birth evidence we hold, not just against this field — see matchState for the four outcomes and exactly how strong each one is. A registrant we know only as "born 1976", or only as "aged 47 when we read the page", is still returned by a search for a full date consistent with that, and is labelled so you can see the birthday was not confirmed.
★ We never publish a month or a day a registry did not print. A year-only registrant has no month and no day at any layer of our system, so this field can never hand you a fabricated New Year's Day that is byte-identical to a real one.
Comparing us against another vendor on this field? Read dobPrecision on both sides or the comparison is meaningless — a vendor that pads a birth year to January 1st and reports it as an exact date will appear to have a date of birth for people whose birthday nobody knows.
The registrant's year of birth, whenever we know it — by any route. Populated both when a registry published only a birth year and when it published a full date (in which case this is that date's year), so year logic reads one field and never has to branch on what a particular registry happens to publish.
dobPrecision — not this field — tells you which of those two it was: year means a registry published the year itself and no more; exact means we read it off the full date in dob.
It is never derived from age. An age is not a date: converting one into a birth year bakes in the moment we read the page and is wrong the day after the person's next birthday. So an age-only record has birthYear: null — and is still reachable by a dob search, because that widening happens when we MATCH your query rather than in what we publish as fact. See matchState: age_match.
null means we do not know the year.
How much of dob the REGISTRY actually published — the authoritative reading of that field, and the only thing that can distinguish a real 1 January birthday from a year-only record.
-
exact — a registry published a full date. dob is YYYY-MM-DD and birthYear is that date's year. A search for that date reports matchState: dob_match.
-
year — a registry published a birth year and nothing more. dob is null, birthYear holds the year. A search for any date inside that year still reaches the record, reported as matchState: year_match — never dob_match, because a year cannot confirm a birthday.
-
year_month — defined for completeness; no source currently uses it.
-
unknown — no birth date and no birth year on file. dob and birthYear are both null. Read age, which several registries publish instead of a date; a search carrying a dob still reaches those records when the published age is consistent with it (matchState: age_match).
A genuine 1 January birthday is served as exact, like any other date, and is not affected by anything above — dobPrecision is the only thing that can distinguish it from a year-only record, which is why it is authoritative rather than decorative.
A merge can IMPROVE this value. One record may combine several registries' entries for the same person (see sources). Where a year-only registry and a full-DOB registry both hold that person and agree on the year, the record carries the full date at exact — the better source wins. The same person can therefore be year under a registry-scoped query and exact nationwide. We never publish a year for a date we hold, and we never invent a date to replace a year; if two sources disagree on the birth year the record keeps its own rather than blending them.
Age as published; captured especially when DOB is unavailable.
Free text, registry vocabulary, NOT an enum. "" when we hold nothing. "Male" and "M" (and "F"/"Female") occur in the SAME response when a search spans registries — normalize before grouping. MAINE and the DISTRICT OF COLUMBIA are a special case worth knowing: neither registry's own website publishes a sex anywhere, so these values come from the jurisdiction's own federal NSOPW feed and use that feed's "M"/"F"/"U" vocabulary. Fill measured 2026-08-04: ME 99.7% (2,880 of 2,888), DC 97.2% (1,028 of 1,058); the residue is records we could not join with certainty and is left empty rather than guessed.
Free text, registry vocabulary, NOT an enum. "" when unknown. IMPORTANT: several registries (California among them) publish ONE column covering race and ethnicity; 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 drops every Hispanic registrant. California measured 2026-08-04: race 51%, ethnicity 48%, either 99.4%. Compute coverage on (race || ethnicity). ME and DC publish NO race on any surface they render (checked live 2026-08-04 on both of Maine's profile renderings, and on DC's ArcGIS layer, roster and CSOSA bulletin) and their federal feeds carry none either, so "" is final there, and there is no ethnicity to fall back to. The national index (NSOPW) publishes no race, ethnicity, height, weight, eyeColor, hairColor or marks on any record of any of the 158 jurisdictions it federates — its API carries ten keys and none of these is among them. A person held by BOTH a state registry and the national index is returned as ONE record carrying the STATE registry's physical description, with both registries listed in sources[]; a person only the national index reaches carries "" for these keys. Tribal registrants are read from the tribe's own per-registrant page and carry them normally.
Free text. "" is the common case (67%). Observed values include "Hispanic", "Non-Hispanic", "Not Hispanic", "Unknown" — normalize before grouping. See race for the single-column registries.
Scars, marks and tattoos as recorded by the registry — free text, no vocabulary, no fixed separator; can run to several hundred characters. IMPORTANT: "" is TWO different facts and this response cannot tell them apart — either the registry publishes no such field at all, or it does and this person has none on file. It is NOT evidence that a person has no tattoos. Verified NOT PUBLISHED by KY, NE, WV, ID, SD, AK, TX, IL, VA, GA, MA and the national NSOPW index; published by ~28 other states, each at its own rate. See race for how the national index and a state registry combine into one record. Per-jurisdiction detail, and how each was verified, in docs/FIELD-DICTIONARY.md section 6.5.
WHICH REGISTRY holds this record — the registering state or territory, NOT where the person lives. Always present; "" when the registry is not a state (see below). 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.
★ registrationState and addressStates are deliberately two fields, and neither is called state. They disagree often: of the 846,485 records held by a two-letter state registry (2026-08-04), 105,028 (12.4%) have NO address in the registering state — they are registered there and living elsewhere — and a further 8,564 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. A single state key would answer one of those two questions and be read as the other.
"" on 35,153 records (2.34%): 29,097 from NSOPW where the federal feed named no member registry, and every record of the TRIBAL registry (5,980 present, spread over 98 tribal registries — re-verified 2026-08-05). A tribe is its own registering authority and is not a state; we do not map one to the other because we have not verified that containment, and a wrong verdict of that kind is worse than an honest blank because nobody re-examines it.
★ THE BLANK WITHHOLDS NOTHING, and that is why it is the right answer. The registering authority is published per record on sources[].registryName ("Cherokee Nation Sex Offender Registry", "Navajo Nation Sex Offender Registry", …) and sources[].recordUrl — finer than a state code would have been — addressStates still says where the person lives, and source.jurisdiction is populated on 100% of records (it is the literal string TRIBAL for these; send jurisdictions: ["TRIBAL"] to scope to them).
Every DISTINCT state appearing in addresses[], first-seen order, no duplicates. Always present; never null.
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 and need not agree. This array does not say which is which — read addresses[] and its type for that. Treating addressStates[0] as "where they live" is the mistake this field exists to prevent.
[] 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: it means the registry published no parseable state. Those records are still returned by state=XX when registrationState matches, which is the only thing that reaches them.
Values are canonicalised to the query.state vocabulary, but 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.
Normalised housing status for the registrant, derived from whichever field the source registry uses to state it — registries express this in different places and different words, and this field answers the question the same way for all of them.
null means the registry does not publish a housing status. It does NOT mean the person is housed: only an affirmative statement from the registry produces housed.
How this record matched the dob (or age) you sent — the complete list of strategies, in descending order of strength. Read this field, not dobVerification, which carries the raw token for backward-compat.
★ THE THING TO UNDERSTAND ABOUT A name + dob SEARCH: registries do not all publish a date of birth. Some publish a birth year, some publish only an age, some publish neither. A dob you send is matched against all of them, so one query reaches every kind of record, and this field tells you exactly how strong the evidence was in each case. Nothing is widened silently: a weaker match is always labelled as one.
-
dob_match — the record holds a full date of birth and it equals the date you sent. The strongest state. dobPrecision: exact.
-
year_match — the record holds a birth year only, and the date you sent falls inside that year. The year agrees; the birthday was never published and is therefore not confirmed. dobPrecision: year, birthYear set, dob null.
-
age_match — the record holds no birth date at all, but a published age, and that age is consistent with the date you sent. Because the registry did not publish a birthday, an age is consistent with two birth years (the person may or may not have had their birthday when the page was published), and both are accepted — so this state is approximately a two-year window around the date you asked for, not a confirmation of it. dobPrecision: unknown, birthYear null, age set.
-
no_dob_age_year — the record has no date, no year and no age: there was nothing to check your date against. It is returned anyway, because the NAME matched (see matchBasis) and an absence of evidence cannot rule the person out — but nothing about the birth date has been verified. unverified is true on these.
-
age_mismatch — the record matched on NAME, publishes only an age, and that age contradicts the date you sent. Returned ONLY when you ask for it with onAgeMismatch: "flag"; by default such a record is dropped. Always unverified — it came back on the name, and it can never be reported as a confirmed identification. Use it when you would rather see a contradiction and judge it than have it silently omitted.
-
dob_mismatch — the record HAD a date, a year or an age, and it conflicted with what you sent. These are filtered out of records; the value is documented so you know a true conflict is excluded rather than shown.
-
null — your query carried no dob and no age, so there was nothing to match on.
If you only want confirmed birthdays, keep dob_match. If you are screening and a missed person is the expensive error, keep year_match, age_match and no_dob_age_year too — that is what they are for, and it is why they are separate values rather than one "matched" flag.
DOB/age match-state vs. the query (Goal 5). Meanings: dob_match = record's full DOB equals the query DOB; year_match = record is year-only (dobPrecision=year) and the year equals the query DOB's year; age_match = matched on age (±1yr) when no full DOB was available; no_dob_age_year = record had no DOB, age, or year to check against; dob_mismatch = the record had a DOB/age that did NOT match (such records are filtered out of records, so this surfaces only where the contract exposes rejected candidates); unverified_no_dob_or_age = kept-but-flagged, nothing to verify; "" = no DOB/age filter was supplied. ★ CORRECTED 2026-08-06. This field previously carried a note saying year_match and no_dob_age_year were "RESERVED" and not emitted. They are emitted, and have been for some time — a year-only record answers year_match and a record with nothing to verify answers unverified_no_dob_or_age (published as no_dob_age_year on matchState). Branch on matchState, whose vocabulary is the clean one.
True when the record had NO DOB and NO age to verify against — shown anyway, flagged.
Example response
{
"recordId": "rec_ab12cd34ef56gh78ij90",
"uuid": "IL:X24A0926",
"matchDetail": {
"strategies": {
"lastName": "exact",
"firstName": "nickname"
},
"fieldsPresent": [
"lastName",
"firstName",
"dob"
]
},
"nicknames": [
"john",
"johnny",
"jack"
],
"birthYear": 1976,
"marks": "Tattooed Arm, left upper; Scar on Chest; Pierced ear, left",
"addresses": [
{
"type": "residence"
}
],
"registrationState": "FL",
"addressStates": [
"FL",
"GA"
],
"stateData": {
"shoeSize": "10½",
"build": "Large",
"shoeWidth": "D"
},
"source": {
"jurisdiction": "IL"
},
"sources": [
{
"jurisdiction": "IL"
}
]
}Changes
No recorded changes to this operation across all 1 revision of this API.