Offendersearch
API Reference · v1.0.0

The Record object

One normalized 76-field schema across every jurisdiction, and how to read its empty values.

Base URL https://api.offendersearch.appThis page as Markdown/docs/record-object.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.

Which jurisdictions is this person on? — sources[]

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.

KeyWhat it holds
jurisdictionThe jurisdiction’s two-letter code — the same vocabulary query.state and jurisdictions accept, so you can send it straight back.
registryNameThe 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.
recordUrlThat jurisdiction’s own link to this registrant, so anyone can verify the entry at the source. "" where the jurisdiction publishes no addressable page per registrant.
scrapedAt / lastCheckedAtWhen this jurisdiction’s copy of the record was first recorded, and when it was last confirmed.
sourceUpdatedAtThe date the jurisdiction itself states it last changed the record. null where a jurisdiction publishes no such date.
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. See recordId in the field table below. source (singular) is whichever entry supplied the top-level values; it always appears in sources[] too.

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:

ValueMeaning
""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.
nullNot known or not applicable; the precise meaning is per-field. On dob it means no full date of birth is published. On matchState it means you did not filter by date of birth or age. 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: see below.

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: some print scars, marks and tattoos on every profile, and a dozen publish no such field anywhere. So marks: "" means either that the jurisdiction publishes no such field, or that it publishes one and this registrant has none recorded — both are properties of the jurisdiction, and neither is evidence about the person. The same reading applies to aliases and to the physical description. Which jurisdictions publish which fields is set out field by field in the Record Object reference.

Maine and the District of Columbia are worth calling out, because it is easy to read them wrong. Neither publishes a race, and there is no ethnicity to fall back to, so race: "" on a Maine or DC record is final. Neither publishes a sex on its own site either; that value comes from the jurisdiction’s own federal (NSOPW) feed, which is why it reads "M"/"F" there rather than "Male". Maine goes further and publishes no physical description at all: state law releases height, weight, eye colour and identifying marks only against a written request naming the requester, so those stay empty permanently. A field is never filled by inference — an empty value is an empty value, not a guess.

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 at all. Read [] as “none known”, never as “none exists”. Vehicles are published by 22 of 56 jurisdictions, so an [] from one of the other 34 says nothing about the person. The sharpest illustration: Utah and Wyoming run identical registry software, and Wyoming’s build publishes work addresses while Utah’s omits the category entirely — so a Utah record will never carry an employment address. Per-jurisdiction rates for every array field are in the Record Object reference.

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 at all about what the jurisdiction holds. Re-request with include before concluding either is genuinely absent. 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

Three details worth stating plainly: 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; and 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. Two things to read before you write the parser. A partial date stays partial — a jurisdiction that publishes only a month or a year gives you "1998-11" or "1998", because inventing a day would make a fabricated date indistinguishable from a real one; the companion datePrecision object names the precision for every date key. And nothing is discarded — where a jurisdiction publishes something that is not a date at all, 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 — you can parse it without a length check. 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. Matching is unaffected: a year-only record still matches any date you query inside that year. See Searching by date of birth.

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. Read the others off the record.

Field reference

For the exhaustive field-by-field reference — every type, every null/empty meaning, every value vocabulary and the per-jurisdiction notes — see docs/FIELD-DICTIONARY.md. Key fields:

FieldTypeDescription
recordId
optional
stringTHE 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
optional
stringThe 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
optional
enumsex_offender today; additive as new types ship.
matchConfidence
optional
numberOur 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
optional
string[]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
optional
object | nullWhich 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
optional
Namefirst, middle, last, suffix, and a pre-formatted full.
aliases
optional
string[]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
optional
string[]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
optional
date | nullA 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
optional
integer | nullTHE 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
optional
enumHOW 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
optional
stringAge in years as a STRING ("58"), not a number. "" when not published. Many registries publish age instead of a DOB.
sex / race / ethnicity
optional
stringDemographics 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
optional
stringPhysical description as published. No unit is attached and formats vary wildly ("5'09\"", "509", "165lbs"). "" when not published.
marks
optional
stringScars, 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
optional
Address[]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
optional
stringWHICH 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
optional
string[]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
optional
OffensePrimary 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
optional
Offense[]The full offense list. [] UNLESS you send include: ["stateData"] — an empty array on a light call says nothing about what the registry holds.
stateData
optional
StateData | nullThe 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
optional
objectTHREE-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
optional
object[]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
optional
SourceRefThe 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
optional
SourceRef[]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
optional
enum | nullHOW 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
optional
enumThe raw match-state token (backward-compat). matchState is the clean, distinct field to read; see Searching by date of birth.
unverified
optional
booleanTrue when the record had no DOB and no age to verify against — shown anyway, flagged.
Example — a real response, identity replaced with placeholders
Record
{
  "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-02T…",
    "lastCheckedAt": "2026-08-02T…",
    "sourceUpdatedAt": null
  },
  "sources": [
    {
      "jurisdiction": "OH",
      "registryName": "State Sex Offender Registry",
      "recordUrl": "https://…",
      "scrapedAt": "2026-08-02T…",
      "lastCheckedAt": "2026-08-02T…",
      "sourceUpdatedAt": null
    }
  ],
  "dobVerification": "",
  "matchState": null,
  "unverified": false
}

What to notice: ethnicity, registrationDate and caseNumber are "" — Ohio does not publish them. So are the conviction-locale fields: Ohio publishes no County of Conviction field, and convictionCounty is the COURT’s county, never the residence county in addresses (which is Butler here — do not read one as the other). federal is null, meaning Ohio publishes no federal-vs-state column at all; only Oklahoma does, and there you would see true or false. flags.absconder is null (Ohio does not publish it) while flags.predator is true (it affirmatively does). stateData and offenses are empty only because this call omitted include; images is empty because this Ohio record has no photo — images is never withheld. matchState is null because the query supplied no dob or age — the record has a date of birth, which is a different thing. And convictionDate is "1988-10-11": Ohio publishes "10/11/1988", which you can still read in datesAsPublished — the value is normalised without discarding what the jurisdiction published.

Two things to key your integration on, from this one record. 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. And recordId is not the identifier to store. It is derived from the merge that produced this person for this query, so the same man returned by a state-scoped search and by a nationwide search can carry two different recordId values — a diff keyed on it reports people as missing who were returned both times. Store sources[].recordUrl with its jurisdiction: that is the jurisdiction’s own permanent address for the registrant and it does not move.

GET/v1/records/{recordId}Auth: X-API-Key

Get a record

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

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
FieldTypeDescription
recordId
required
stringThe recordId (rec_…) or the registry uuid from a search result. Both resolve.
Request
curl https://api.offendersearch.app/v1/records/rec_4b1e \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
Response
200 OK
{
  "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.