Offendersearch API Searches API

The Searches API from Offendersearch API — 3 operation(s) for searches.

OpenAPI Specification

offendersearch-api-searches-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  title: Offendersearch Searches 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: Searches
paths:
  /v1/searches:
    post:
      operationId: asyncSearch
      summary: Asynchronous search — built for batch and high-volume work
      description: '**The batch endpoint.** Submit the search, get a job id back immediately, and collect the result when it is ready — poll `GET /v1/searches/{searchId}` or supply a `webhookUrl` and we POST the finished envelope to it.


        Because you are not holding a connection open there is no request-timeout ceiling on the work, so every named jurisdiction runs to completion. Use it for bulk screening, periodic re-screens of a roster you already hold, backfills, unattended scheduled jobs, and broad national sweeps.


        Use `POST /v1/search` instead when a person is waiting on the answer — that is the interactive endpoint and it returns in one round trip. Both read the same corpus at the same `freshness` tier and return the same envelope; the choice is about how you collect the result, not how current it is. To submit many searches at once, `POST /v1/batch` takes up to 1,000 queries in a single request.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AsyncSearchRequest'
      responses:
        '202':
          description: Search accepted; poll `resultsUrl` for status + results.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AsyncSearchAccepted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/Unprocessable'
      tags:
      - Searches
  /v1/searches/{searchId}:
    get:
      operationId: getSearch
      summary: Fetch an async search's status + results
      description: 'Returns the job''s current state. While `pending`/`running` the body carries empty `records`/`sourceStatus` and a zeroed `counts`; on `error` it carries an `error` string; when finished it is the full SearchResponse.

        '
      parameters:
      - name: searchId
        in: path
        required: true
        schema:
          type: string
        example: srch_9f2c1a7b
      responses:
        '200':
          description: Search status + results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
      tags:
      - Searches
  /v1/searches/{searchId}/proof:
    post:
      operationId: makeProof
      summary: Per-registry look-alike proof docs (internal add-on — NOT the verification report)
      description: 'Internal per-registry add-on, distinct from the customer-facing consolidated verification report (`POST /v1/report`). Renders a per-registry look-alike PDF/HTML for ONLY the registries the caller names. Requires billing (402 otherwise); it carries an extra per-document charge. Never generated for all sources implicitly. `registries` is required. A registry with no look-alike template yet is skipped and NOT billed (its document row carries an empty `url` + explanatory `note`).

        '
      security:
      - ApiKeyAuth: []
      parameters:
      - name: searchId
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProofRequest'
      responses:
        '200':
          description: Proof bundle (one verification-report document per requested registry)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofBundle'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Payment required — billing not enabled on the account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/Unprocessable'
      tags:
      - Searches
components:
  schemas:
    AsyncSearchRequest:
      allOf:
      - $ref: '#/components/schemas/SearchRequest'
      - type: object
        properties:
          webhookUrl:
            type: string
            format: uri
            nullable: true
            description: Optional URL to notify when the async search completes.
    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%, ethnic

# --- truncated at 32 KB (109 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/offendersearch-api/refs/heads/main/openapi/offendersearch-api-searches-api-openapi.yml