openapi: 3.2.0
info:
title: Offendersearch Records API
version: 1.0.0
description: "National sex-offender search across all US states and territories, unified behind one API. Synchronous by default (call and wait) for interactive use; an asynchronous mode returns a job handle and is built for batch and high-volume work. Full offenders.io field parity plus scored matches, per-source provenance, freshness options, DOB/age match-state fidelity, and a consolidated verification-report PDF (`POST /v1/report`) with a source citation on every record. Criminal records land later as an additive `recordType` — the contract does not change.\n\n## Authentication\nMost customer endpoints authenticate with an API key sent in the `X-API-Key` header (`ApiKeyAuth`). The primary synchronous search additionally accepts a signed **session token** (`Authorization: Bearer <token>`) so the dashboard console can run searches on behalf of a signed-in user. The offenders.io compatibility endpoint accepts the key three ways for drop-in parity: `X-API-Key`, `Authorization: Bearer <key>`, or `?key=<key>`. Account/dashboard endpoints (`/v1/account`, `/v1/keys`, `/v1/usage`, ...) require a session token. Internal ops endpoints require the separate `X-Admin-Key` credential.\n\n## Freshness & billing\n`freshness` is a per-request parameter with two values — `daily` (the DEFAULT when omitted) and `weekly`.\n\n* **`daily`** is the freshest data we publish. Our sweep of every registry runs\n on a daily cycle, so a `daily` answer is assembled from the newest snapshot we\n hold of each one — in practice, almost real time. Ask for it when currency is\n the point. It bills the **+$0.01/call daily-freshness surcharge** on top of the\n base per-call rate (admin-overridable per customer).\n* **`weekly`** is also fresh. Identity is essentially identical to `daily` — the\n same people, names, aliases, offenses, addresses and photos — and what can lag\n is only the most recent movement, a registration or an address change from the\n last day or so. Right for bulk screening and periodic re-screens. **No\n surcharge.**\n\n\n**`freshness` describes the answer; it does not filter it.** No registry is ever withheld from a result because of when it was last swept: every registry covering the query contributes, from the newest snapshot we hold of it. `status` never becomes `partial` for a snapshot age, and `warnings[]` never carries a staleness sentence.\n\n**★ EVERY DATE IS ISO-8601 — BREAKING CHANGE, 2026-08-04.** Every date-shaped field in a record now comes back as ISO-8601: `dob`, the four `offense.*Date` fields, and the five `stateData` date fields. They used to be the registry's own string passed through byte-for-byte, so a single response could carry `\"2003-03-31\"`, `\"10/11/1988\"`, `\"11-29-1987\"` and `\"Aug. 10, 1987\"` in the same key — `offense.offenseDate` was ISO on only 45.4% of its populated values, in seven distinct shapes. **If you wrote a lenient parser or a per-state format table, you can delete it; if you compared these values as raw strings, or stored them in a text column and matched on it, those comparisons will change once.**\nThree rules govern the new values, and the third is the one to read carefully:\n1. A full date is `YYYY-MM-DD`. 2. **A partial date stays partial.** A registry that publishes only a month or\n only a year yields `YYYY-MM` or `YYYY` — we never invent a day. The companion\n `datePrecision` object on `offense` and `stateData` names the precision\n explicitly, exactly as `dobPrecision` has always done for `dob`.\n3. **Nothing is discarded.** A value we cannot read as a date — a crime\n description in an offence-date cell, a `registrationEnds` of \"Life\" — is NOT\n served as a fake date and NOT silently blanked. The field is `\"\"`, the\n precision is `unparseable`, and the registry's literal text is preserved in\n the `datesAsPublished` object beside it.\n\n`source.scrapedAt` / `.lastCheckedAt` / `.sourceUpdatedAt` were already ISO-8601 timestamps and are unchanged. `dob` was already `YYYY-MM-DD` and is unchanged.\n\n**There is no date FILTER on the offense or stateData dates.** `dob` is the one date you can narrow a query on; read the rest off the record.\n**Provenance is never traded for normalisation.** The registry's own text is kept beside the normalised value, not overwritten by it, which is why `datesAsPublished` still carries what the source actually printed — including the 38,818 `registrationEnds` cells that hold a registration DURATION (\"15 Years\", \"Lifetime\") rather than a date.\n\n**Where currency is published — and where it is not.** Per record, `record.source.scrapedAt` is the moment we ingested the snapshot that record came from. Per registry, `GET /v1/sources` publishes `health.lastSuccessAt` and `health.ageSeconds` live and without an API key. Both are things you ask for. Neither is attached to a search answer: `sourceStatus[]` carries no age fields, because an answer that contains every matching record does not need a caveat about our sweep schedule.\n\n★ **Freshness and completeness are different questions, and only one of them makes an empty result unsafe.** `freshness` says how OLD an answer is; it says nothing about whether the answer is WHOLE. A registry swept ten minutes ago can still fail to be searched to the end — a deadline, or a candidate set larger than one search may examine — and then a `0` from it means UNKNOWN, not NO MATCH. That is `counts.sourcesIncomplete` / `sourceStatus[].incomplete`, it is unrelated to freshness, and it is the one signal that must never be ignored. See API-CONTRACT.md §5.0.\n\n**Migrating from a pre-2026-08-04 integration.** `counts.sourcesStale`, `counts.sourcesDegraded`, `counts.sourcesOmitted`, `counts.recordsFromStaleSources`, `freshnessDetail`, and `sourceStatus[].freshnessSatisfied` / `.degraded` / `.omitted` / `.ageSeconds` / `.lastSuccessAt` were removed and are no longer emitted. If you were gating on any of them, gate on `counts.sourcesIncomplete` instead — it asks whether your ANSWER is whole, which is the question those keys were being used to ask. `onStale` is deprecated but still accepted, so an older request body keeps working.\n\nBilling is required for every account; the ONLY free call is one scoped exclusively to statutorily non-commercial jurisdiction(s) (e.g. state=CA), which has nothing commercially billable. **Batch billing is per-search:** `POST /v1/batch` is one HTTP request but each search in it is billed as its own search (per-row freshness included) — a batch of 100 = 100 billable searches. The consolidated verification report (`POST /v1/report`) is a separate, callable endpoint any valid key may use (no per-key entitlement), billed **+$0.02 per document** (admin-overridable per customer). A separate `proof` add-on also carries an extra per-document charge and requires billing (402 otherwise); it is NOT the customer verification report.\n"
servers:
- url: https://api.offendersearch.app
security:
- ApiKeyAuth: []
tags:
- name: Records
paths:
/v1/records/{recordId}:
get:
operationId: getRecord
summary: Fetch a single normalized record by recordId or uuid
description: '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.
'
parameters:
- name: recordId
in: path
required: true
description: The `recordId` (`rec_…`) or the registry `uuid` from a search result.
schema:
type: string
example: rec_ab12cd34ef56gh78ij90
responses:
'200':
description: The record
content:
application/json:
schema:
$ref: '#/components/schemas/Record'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: 'The identifier matches records in more than one jurisdiction. `detail` names them. Disambiguate with the compat endpoint''s `state`, or use `recordId`.
'
tags:
- Records
components:
responses:
Unauthorized:
description: Missing or invalid credential.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Name:
type: object
properties:
first:
type: string
middle:
type: string
last:
type: string
suffix:
type: string
full:
type: string
description: 'Computed: first middle last suffix, joined.'
Record:
type: object
description: 'One normalized offender record (offenders.io-parity superset + our extensions).
**Every key is always present — we never omit a key.** Read the empty values carefully, because they mean different things:
* `""` on a string field means **the source registry does not publish that field for this record**. Registries differ enormously (Maine publishes no sex/race/height at all). This is a property of the source, not a transient gap on our side.
* `null` on `dob` means the registry publishes no date of birth — check `age`, since many portals publish age only.
* `null` on `flags.absconder` / `flags.predator` means **unknown / not published**. It is NOT the same as `false`, which means the registry affirmatively says the person is not an absconder/predator.
* `null` on `matchedName` means your query supplied no name; `null` on `matchState` means your query supplied no `dob` or `age`.
* `null` on `stateData` (together with `offenses: []` and `images: []`) means **you did not request deep detail** — the engine strips all three unless the request carries `include: ["stateData"]`. This is the one empty value that says nothing about source coverage: re-request with `include: ["stateData"]` before concluding data is absent.
'
properties:
recordId:
type: string
description: '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.
'
example: rec_ab12cd34ef56gh78ij90
uuid:
type: string
description: '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.
'
example: IL:X24A0926
recordType:
type: string
enum:
- sex_offender
default: sex_offender
matchConfidence:
type: number
nullable: true
description: '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.
'
matchBasis:
type: array
items:
type: string
description: '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.
'
matchDetail:
type: object
description: 'The UNMIXED reading of `matchBasis` — the same information, already split into the three separate things that array encodes, so you never have to know which token shape means what. Always present. `matchBasis` is left byte-for-byte unchanged for existing integrations; NEW CODE SHOULD READ THIS FIELD.
Caution on `strategies.lastName: "exact"` — it means the queried surname equalled the record''s surname OR one TOKEN of a compound/hyphenated surname ("Hamilton-Smith" matches a "Smith" query, uncapped). It is not a guarantee of string equality; whole-surname matches simply sort first.
'
properties:
strategies:
type: object
additionalProperties:
type: string
enum:
- exact
- nickname
- initial
- prefix
- fuzzy
- middle
- absent
description: "HOW each field matched, as `{field: strategy}`. The three possible keys are `lastName`, `firstName` and `alias`; `alias` is present ONLY when an alias rather than the registered name produced the hit.\n\nThe strategy vocabulary is CLOSED — these seven values are all that can appear, and an unrecognised engine token would surface under `other` instead. Listed strongest first, with the ceiling each imposes on `matchConfidence` and the fields it can appear on:\n\n* `exact` (rank 1, no cap) — the field equals your value, or equals one\n token of a compound surname. On `lastName`, `firstName`, `alias`. On\n in every `match` mode and cannot be disabled.\n\n* `nickname` (rank 2, <= 0.75) — a known given-name equivalence\n (bob -> Robert). **`firstName` only**: there is no surname nickname\n table, and requesting one on `lastName` is a 422.\n\n* `initial` (rank 2, <= 0.60) — a single letter against a full given\n name (J -> John), either direction. `firstName` only.\n\n* `prefix` (rank 3, <= 0.60) — the field STARTS WITH your value,\n minimum 3 characters. On `lastName`, `firstName`, `alias`.\n\n* `fuzzy` (rank 4, <= 0.60) — spelling variant or typo within a\n length-scaled edit-distance budget. On `lastName`, `firstName`,\n `alias`.\n\n* `middle` (rank 5, <= 0.60) — the given name you sent is the\n person's MIDDLE name, matched as a whole token of the registered\n legal name (minimum 2 characters; never prefixed, never fuzzy, and\n an initial is not a name in either direction). `firstName` only.\n `matchedNameType` stays `\"legal\"` — a middle name IS part of the\n registered name — and `signals` additionally carries\n `matchedMiddleName`. It ranks below every first-name strategy on\n purpose: some names sit overwhelmingly in the middle slot (`Lee`\n is 20x commoner there than as a first name), so for those names\n this arm draws on a much larger pool and a coincidence is\n correspondingly likelier. Tried only after the registered first\n name and every alias have failed, so a stronger basis is always the\n one reported.\n\n* `absent` (rank 6, <= 0.60) — the record publishes NO first name, so\n your query could not be disproved; the record is kept and flagged,\n never confirmed. `firstName` only.\n\n\nBy `match` preset: `strict` enables `exact` alone (and aliases are off). `balanced` adds `nickname`, `initial`, `prefix`, `fuzzy` and `middle` on `firstName` only. `broad` additionally allows `prefix` and `fuzzy` on `lastName`. `absent` can occur in any mode. `initial` and `absent` are engine behaviours and cannot be named in `nameMatch`, which accepts `exact`/`prefix`/`nickname`/`fuzzy`/`middle` on `firstName` and `exact`/`prefix`/`fuzzy` on `lastName`.\n\nRank is the ordering contract, not a score: results sort by surname rank then given-name rank, and legal-name hits sort before alias-only hits. An alias-only hit caps confidence at 0.55, the lowest value the engine produces. Every cap is lifted only when you supplied a `dob` and the record's full date of birth matched it.\n\nFor picking an auto-accept threshold, gate on this object rather than on `matchConfidence` alone (several strategies share one ceiling): auto-accept `exact` on every queried field with `matchedNameType: \"legal\"`; review anything `nickname`, `initial`, `prefix`, `fuzzy` or `middle`; never auto-accept an alias-only hit or `firstName: \"absent\"`. See API-CONTRACT.md section 3.8.\n"
example:
lastName: exact
firstName: nickname
fieldsPresent:
type: array
items:
type: string
description: 'Which identity fields the RECORD carries (`lastName`, `firstName`, `dob`). Pure data completeness — says nothing about your query.
'
example:
- lastName
- firstName
- dob
signals:
type: array
items:
type: string
enum:
- matchedLegalName
- matchedAlias
- matchedMiddleName
- unverifiable
description: 'Boolean-ish flags about the match as a whole. `matchedMiddleName` accompanies `strategies.firstName: "middle"` and is repeated here so a caller who screens on `signals` alone still sees that the given name matched the person''s middle name rather than their first.
'
matchedNameType:
type: string
nullable: true
enum:
- legal
- alias
description: Whether the registered legal name or an alias produced the hit. Null when no name was queried.
verification:
type: string
nullable: true
description: The raw `dobVerification` token, or null when no dob/age was queried.
other:
type: array
items:
type: string
description: 'Present ONLY if the engine emitted a token this mapping does not recognise, so a new token can never silently vanish. Normally absent.
'
matchedName:
type: object
nullable: true
description: 'WHICH name matched your query, and whether it was the registered legal name or an alias. null when the query supplied no name. Read this alongside matchBasis so an alias or partial-name hit is never mistaken for a registered-name match.
'
properties:
value:
type: string
description: The literal name string that matched.
type:
type: string
enum:
- legal
- alias
description: legal = the registered name; alias = one of the record's aliases.
name:
$ref: '#/components/schemas/Name'
aliases:
type: array
items:
type: string
description: '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.
'
nicknames:
type: array
items:
type: string
description: '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.
'
readOnly: true
example:
- john
- johnny
- jack
dob:
type: string
format: date
nullable: true
description: "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.\n\n* `\"1964-08-13\"` — a full date the registry printed. `dobPrecision: exact`.\n* `null` — no full date was published. **The record may still tell you a\n great deal about when this person was born:** read `birthYear` (present\n whenever the year is known) and `age` (published by many registries that\n publish no date at all). `dobPrecision` says which.\n\n\n★ **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.\n\n★ **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.\n\n**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.\n"
birthYear:
type: integer
nullable: true
minimum: 1900
maximum: 2100
example: 1976
description: '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.
'
dobPrecision:
type: string
enum:
- exact
- year
- year_month
- unknown
description: "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.\n\n* `exact` — a registry published a full date. `dob` is `YYYY-MM-DD` and\n `birthYear` is that date's year. A search for that date reports\n `matchState: dob_match`.\n\n* `year` — a registry published a birth year and nothing more. `dob` is\n `null`, `birthYear` holds the year. A search for **any** date inside\n that year still reaches the record, reported as\n `matchState: year_match` — never `dob_match`, because a year cannot\n confirm a birthday.\n\n* `year_month` — defined for completeness; no source currently uses it.\n* `unknown` — no birth date and no birth year on file. `dob` and\n `birthYear` are both `null`. **Read `age`**, which several registries\n publish instead of a date; a search carrying a `dob` still reaches\n those records when the published age is consistent with it\n (`matchState: age_match`).\n\n\nA 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.\n\n**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.\n"
age:
type: string
description: Age as published; captured especially when DOB is unavailable.
sex:
type: string
description: '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.
'
race:
type: string
description: '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.
'
ethnicity:
type: string
description: '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.
'
height:
type: string
weight:
type: string
eyeColor:
type: string
hairColor:
type: string
marks:
type: string
description: '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 res
# --- truncated at 32 KB (65 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/offendersearch-api/refs/heads/main/openapi/offendersearch-api-records-api-openapi.yml