Offendersearch API Search API

The Search API from Offendersearch API — 1 operation(s) for search.

Operations 1

POST /v1/search Synchronous search (PRIMARY — call and wait) #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/offendersearch-api-search-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

offendersearch-api-search-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  title: Offendersearch Search 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: Search
paths:
  /v1/search:
    post:
      operationId: syncSearch
      summary: Synchronous search (PRIMARY — call and wait)
      description: 'Searches the full dataset by default (or the jurisdictions you name) in a single call and returns scored, de-duplicated results in the response. Served from the current snapshot for speed. Set `deadlineMs` to bound how long you wait; with `onDeadline: partial` the response returns whatever completed within that bound, with per-jurisdiction status in `sourceStatus`; with `onDeadline: error` a forced-partial result returns 504 instead. Accepts an API key OR a session token.

        '
      security:
      - ApiKeyAuth: []
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SearchRequest'
      responses:
        '200':
          description: Search results (complete, or partial when a deadlineMs bound is hit)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: 'Billing required for the requested `proof` documents (account has no billing enabled).

            '
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '504':
          description: Deadline was hit and `onDeadline=error` was set (forced-partial).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      tags:
      - Search
components:
  schemas:
    Error:
      type: object
      description: Standard FastAPI error body.
      properties:
        detail:
          type: string
          description: Human-readable error message.
          example: Missing or invalid API key. Send it in the X-API-Key header.
    Query:
      type: object
      additionalProperties: false
      description: 'The person/location query. All fields are optional; supply what you have. DOB/age drive the match-state (see Record.dobVerification). offenders.io parity filters (q/address/prefixMatch/fuzzy/createdAt*/updatedAt*/page/ perPage) are applied uniformly across ALL jurisdictions.

        '
      properties:
        firstName:
          type: string
          description: Given name.
          example: John
        lastName:
          type: string
          description: Surname (primary match key).
          example: Smith
        dob:
          type: string
          format: date
          description: 'Date of birth, `YYYY-MM-DD`. Optional, and it NARROWS — a record whose published birth evidence conflicts with this date is excluded.


            ★ **It does not require us to hold a date of birth for the person.** The date you send is matched against every kind of birth evidence a registry publishes: a full date, a birth year alone, or a published age alone. Records with none of those are still returned on the name, flagged unverified. `matchState` on each record tells you which of those happened and how strong it was — read it there; the four outcomes are `dob_match`, `year_match`, `age_match` and `no_dob_age_year`.

            '
          example: '1985-06-14'
        age:
          type: integer
          description: Age filter. Used when DOB is unavailable; matched ±1 year for birthday drift.
          example: 39
        city:
          type: string
          description: Residence city filter.
          example: Chicago
        state:
          type: string
          description: '2-letter USPS state/territory code (`FL`), case-insensitive; the full state or territory name (`Florida`) is also accepted and means exactly the same thing. A value we cannot resolve to a jurisdiction is rejected with 422 — it is never silently treated as `matches nothing`.


            ★ IT IS A UNION, AND YOU SHOULD KNOW WHICH HALF MATCHED. `state` keeps a record when EITHER one of its `addressStates` is that state (they live there) OR its `registrationState` is (that state''s registry holds them). It does NOT change which registries run — all 58 are searched and `state` narrows the answer. (Until 2026-08-05 it silently scoped the fan-out to that state''s own registries, which hid anyone registered elsewhere while residing there; use `locationScoped: true` if you explicitly want the cheaper, narrower search.) Those two halves are genuinely different populations: 105,028 records are registered in a state where they have no address on file, and 70,973 records carry no address state at all and are reachable ONLY by the registration half. Every record comes back carrying both `registrationState` and `addressStates`, so you can tell which half answered without a second call — narrow to residents with `addressStates`, or to a registry''s roster with `jurisdictions: ["FL"]`, which is the registration-only filter.

            '
          example: IL
        zipcode:
          type: string
          description: Residence ZIP (first 5 used).
          example: '60614'
        address:
          type: string
          description: 'Parity: fuzzy street-address match (every token must appear in some record address).'
        lat:
          type: number
          description: Latitude for GIS radius search.
          example: 41.9
        lng:
          type: number
          description: Longitude for GIS radius search.
          example: -87.65
        radiusMiles:
          type: number
          maximum: 100
          description: 'Parity: GIS radius in miles. Defaults to 1 when lat/lng given; capped at 100.'
        q:
          type: string
          description: 'Parity: free-text across name/alias/address/city/state/zip (all tokens must appear).'
        fuzzy:
          type: boolean
          description: 'Parity: offenders.io fuzzy toggle. true -> balanced match; false -> strict. Overrides `SearchRequest.match`.'
        prefixMatch:
          description: 'PARTIAL-NAME SEARCH. Treat the name(s) you supplied as the START of a name: `thom` returns Thomas, Thompson and Thomason. Accepts "firstName", "lastName", "both" (prefix-match BOTH fields at once), or a list. Matched against every recorded ALIAS as well as the registered legal name; `matchedName` on each record says which one matched. MINIMUM 3 CHARACTERS — a shorter prefix returns 422. Prefix matching is a strict superset of exact matching, and an exact match always ranks above a prefix match. `nameMatch` overrides this.

            '
          oneOf:
          - type: string
            enum:
            - firstName
            - lastName
            - both
          - type: array
            items:
              type: string
              enum:
              - firstName
              - lastName
        nameMatch:
          type: object
          description: 'EXPLICIT PER-FIELD CONTROL over how names are matched — the fine-grained form of `prefixMatch`/`match`. Anything omitted from a field''s list is OFF for that field. Exact matching is always on and cannot be disabled. Overrides both `prefixMatch` and `SearchRequest.match`.

            '
          properties:
            firstName:
              type: array
              description: Strategies for the first name. prefix requires >=3 characters; middle requires >=2.
              items:
                type: string
                enum:
                - exact
                - prefix
                - nickname
                - fuzzy
                - middle
            lastName:
              type: array
              description: Strategies for the last name. prefix requires >=3 characters.
              items:
                type: string
                enum:
                - exact
                - prefix
                - fuzzy
            aliases:
              type: boolean
              default: true
              description: Also apply these strategies to every recorded alias.
        faceId:
          type: string
          description: Parity input ONLY — facial search is NOT supported; supplying it returns 422 (never a faked face match).
        createdAtStart:
          type: string
          format: date-time
          description: 'Parity: lower bound on source.scraped_at (when we first recorded the record).'
        createdAtEnd:
          type: string
          format: date-time
          description: 'Parity: upper bound on source.scraped_at.'
        updatedAtStart:
          type: string
          format: date-time
          description: 'Parity: lower bound on source.source_updated_at (when the source last changed the record).'
        updatedAtEnd:
          type: string
          format: date-time
          description: 'Parity: upper bound on source.source_updated_at.'
        page:
          type: integer
          minimum: 1
          description: 'Parity: 1-based page number. Omit it and the whole match set is returned in a single page — up to the response cap. ★ ABOVE THE CAP, OMITTING IT IS NOT THE SAME AS ASKING FOR EVERYTHING: an unpaginated answer larger than `cappedLimit` (4,000) is trimmed to that many records and says so with `capped: true`, and `cappedOmittedSources` names the registries that contributed nothing to it. A PAGINATED request is never trimmed — it walks the entire match set and `capped` stays `false`. So `counts.records` vs `counts.recordsReturned` is the pair to branch on: if they differ, send `perPage` and page through. Most searches never reach this — a `name + DOB` query returns tens of records — but a bare common surname does. A page past `totalPages` returns an empty `records` array (changed 2026-08-05; it used to re-serve the last page).

            '
        perPage:
          type: integer
          minimum: 1
          description: 'Parity: page size. Defaults to 20 when paginating. Sending it turns pagination ON, which is what makes an over-cap match set fully reachable. Clamped to `cappedLimit`, so a single page can never rebuild the oversized response the cap exists to prevent.

            '
        ageTolerance:
          type: integer
          minimum: 0
          maximum: 10
          default: 1
          description: 'How many years of slack the age comparison allows, when the query carries a `dob` and the record publishes only an `age`. Eleven registries publish an age and no date at all, so a `name + DOB` search has to compare a searched DATE against a published AGE; how much slack that allows is a risk decision, and it is yours. The default of 1 is not arbitrary — a published age with an unpublished birthday is consistent with two birth years, and the registry may have computed it a refresh before we read it, so the age is anchored to the date we read that page rather than to today. Raise it for a high-recall screening pass: more same-name strangers, fewer missed true matches.

            '
        onAgeMismatch:
          type: string
          enum:
          - drop
          - flag
          default: drop
          description: 'What to do with a record that matches on NAME but whose published age contradicts the searched `dob`. `drop` (default) omits it. `flag` RETURNS it, labelled `matchState: "age_mismatch"`, and lets you judge — "silently omitted" and "checked, and the age contradicts your date" are different facts. A flagged record is always `unverified` and can never be reported as DOB-confirmed.

            '
    SourceRef:
      type: object
      description: Provenance stamp — mandatory on every record's primary source.
      properties:
        jurisdiction:
          type: string
          description: Jurisdiction code, e.g. IL.
          example: IL
        registryName:
          type: string
        recordUrl:
          type: string
          format: uri
          description: Link to the underlying record.
        scrapedAt:
          type: string
          format: date-time
          nullable: true
          description: createdAt basis — when we first recorded it.
        lastCheckedAt:
          type: string
          format: date-time
          nullable: true
        sourceUpdatedAt:
          type: string
          format: date-time
          nullable: true
          description: updatedAt basis — when the source last changed the record.
    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.'
    ProofRequest:
      type: object
      description: 'Request body for the per-registry look-alike proof add-on (NOT the consolidated verification report — see ReportRequest / `POST /v1/report`). Available to billing-enabled accounts and carries an extra charge per document. Name the registries and the format — proof is never generated for all sources implicitly.

        '
      required:
      - registries
      properties:
        registries:
          type: array
          minItems: 1
          items:
            type: string
          description: Jurisdiction codes to render proof for (e.g. ["IL","IN"]). Each renderable one is billed.
        format:
          type: string
          enum:
          - pdf
          - html
          default: pdf
    SearchResponse:
      type: object
      description: 'Synchronous result, or the state of an async job. While pending/running, `records`/`sourceStatus` are empty and `counts` is zeroed; on error, `error` is set and other fields may be omitted.

        '
      properties:
        searchId:
          type: string
          example: srch_9f2c1a7b
        status:
          type: string
          enum:
          - complete
          - partial
          - running
          - pending
          - error
          description: 'complete=every registry was searched to the end; partial=at least one was NOT (deadline or candidate-cap truncation) or a source errored; pending/running=async job in flight; error=async job failed. ★ `partial` is NEVER about how old the data is — a snapshot''s age does not affect `status`. When `status` is `partial`, read `counts.sourcesIncomplete` before treating an empty `records` as an answer (API-CONTRACT.md §5.0).

            '
        freshness:
          type: string
          enum:
          - daily
          - weekly
          - standard
          description: 'The freshness tier the caller asked for, echoed back — also the tier billed. Normally `daily` or `weekly`. `standard` appears only when the caller sent the legacy value (billed as `daily`). An unrecognised request value is echoed as `daily`. For how current the DATA is, read `record.source.scrapedAt` per record or `GET /v1/sources` per registry.

            '
        elapsedMs:
          type: integer
          description: Server processing time for the search, in ms.
        counts:
          type: object
          description: Roll-up counts.
          properties:
            records:
              type: integer
              description: 'TOTAL matched records, BEFORE the page slice — not `records.length`. The envelope also carries `page`, `perPage` and `totalPages`, so you do not need to compute the page count yourself.

                '
            recordsReturned:
              type: integer
              description: 'How many records THIS RESPONSE carries. Equal to `counts.records` on virtually every search; when `capped` is true it is the cap and `counts.records` is the true match count, so the two together tell you exactly how much of the answer you are holding. Always present.

                '
            sourcesQueried:
              type: integer
              description: 'Registries queried in this search. A default search — INCLUDING one with `query.state` — queries all 58. Only an explicit `jurisdictions` list or an explicit `locationScoped: true` reduces it.

                '
            sourcesSkippedByScope:
              type: integer
              description: 'Registries NOT queried because the caller sent `locationScoped: true`. Always present; 0 on every default search. Above 0 the answer excludes anyone registered by a registry outside the requested state, however they match the residence filter — those registries contributed nothing because they were not searched, which is not the same as finding no match.

                '
            sourcesIncomplete:
              type: integer
              description: 'Registries that could NOT BE SEARCHED TO THE END — either the registry did not answer within its 15s deadline, or the query matched more than 15,000 candidate rows in that registry and only the first 15,000 were examined (2,000 for the speculative typo arm of `match: "broad"`). This is NOT the same as paging: `page`/`perPage` slice an answer you can walk in full, and never cost you a record. See `incompleteReason`. Anything above 0 means `records` is a LOWER BOUND: a matching person may exist in that registry and simply never have been reached. ★ AN EMPTY `records` ARRAY WITH `sourcesIncomplete > 0` IS NOT EVIDENCE THAT A PERSON IS UNREGISTERED. `sourceStatus[].incomplete` identifies which registries, and `warnings[0]` states it in prose. See API-CONTRACT.md §5.0.

                '
            sourcesComplete:
              type: integer
              description: '★ HOW MANY REGISTRIES ACTUALLY ANSWERED THIS REQUEST — the positive count, and the one to build a retry policy on. Always present. `status: "complete"` means exactly `sourcesComplete == sourcesQueried`; both come from the same internal test, so the word and the number can never disagree. READ IT AS A RATIO. `status: "partial"` is one word covering everything from "one registry was slow" to "almost none of them answered" — on 2026-08-05 the identical nationwide body returned 207 records with 57 of 58 registries answering, and 9 records with 7 of 58 answering, and BOTH said `partial`. `sourcesComplete/sourcesQueried` is what tells those apart: hold-and-retry on a low ratio, accept a high one. DO NOT compute this as `sourcesQueried - sourcesIncomplete`. That subtraction over-counts: a registry that errored, or that is closed to commercial use (`status: "restricted"`), or that does not cover your query (`"no_coverage"`) contributed nothing and sets no `incomplete` flag, so the subtraction credits it as having answered. This field does not. It is a fact about THIS REQUEST''S fan-out and says nothing about when any registry was last collected.

                '
        warnings:
          type: array
          items:
            type: string
          description: 'Plain-language notices about anything that SHORTENED this answer — one sentence per condition, naming the registries. ALWAYS present; `[]` means nothing did, so `warnings == []` is a valid completeness check and the cheapest one available. The loudest entry, always first when present, begins "INCOMPLETE SEARCH:" and means one or more registries could not be searched to the end. Freshness never produces a warning.

            '
        page:
          type: integer
          description: 'The page you asked for, echoed back. `1` when the caller did not paginate. ★ CHANGED 2026-08-05: a `query.page` past `totalPages` now returns an EMPTY `records` array instead of being CLAMPED to the last page. The clamp re-served the last page under every page number you sent (pages 5, 6 and 50 of a 4-page result all carried page 4''s records), so the canonical `while records: page += 1` client NEVER TERMINATED — and /v1/search meters one billable call per request, so every spin was charged. `counts.records` and `totalPages` are unchanged, and walking 1..totalPages returns exactly the same records it always did.

            '
        perPage:
          type: integer
          description: 'Page size actually applied. NOTE: when the caller did NOT paginate this equals the TOTAL record count (the whole set was returned in one page), not the 20 default.

            '
        totalPages:
          type: integer
          description: Total pages at this `perPage`. `1` when the caller did not paginate.
        sourceStatus:
          type: array
          items:
            $ref: '#/components/schemas/SourceStatus'
          description: Per-source outcome for this search.
        records:
          type: array
          items:
            $ref: '#/components/schemas/Record'
        proof:
          allOf:
          - $ref: '#/components/schemas/ProofBundle'
          description: Proof bundle when `proof` was requested; otherwise {status:none}.
        error:
          type: string
          nullable: true
          description: Set only when an async job failed (status=error).
        capped:
          type: boolean
          description: 'TRUE when the search matched more records than one response may carry and the list was cut to `cappedLimit`. Always present, `false` on virtually every search. **Branch on this field, not on prose and not by comparing counts.** When it is true, `counts.records` is still the true number of people who matched — you are holding the first `cappedLimit` of them. A capped response is not an error and not a `partial`: every record in it is a real match, and the registries were all searched to the end. To see the rest, narrow the search (see `cappedReason`).

            '
        cappedReason:
          type: string
          nullable: true
          enum:
          - responseLimit
          description: 'Why the response was capped; `null` when `capped` is false. `responseLimit` is the fixed ceiling on how many records one response may carry. It exists so that a whole-registry sweep cannot be issued as a single call; it is not a limit on how much of the corpus you may reach, only on how much of it arrives at once.

            '
        cappedLimit:
          type: integer
          nullable: true
          description: 'The ceiling that was applied (currently 4000), or `null` when `capped` is false. Read it from the response rather than hard-coding it.

            '
        cappedOmittedSources:
          type: array
          items:
            type: string
          description: 'The registries whose records are ENTIRELY absent from this response because of the cap — sorted, and `[]` on every uncapped search (always present). `capped` / `cappedLimit` tell you HOW MANY records did not fit; this tells you WHOSE. Records are ordered by match confidence rather than by registry, so a registry''s whole contribution can fall below the line together. ★ NO PAGE OF THIS RESPONSE REACHES A REGISTRY NAMED HERE: `totalPages` counts only the `cappedLimit` records carried, so walking every page does not recover them. Their `sourceStatus[].matched` counts are still exact. The recovery is to NARROW the search until the answer fits — add `state`, `city`, `zipCode`, `firstName` or `dob`/`age`, or name registries directly with `jurisdictions`. Example: `{"lastName":"Smith"}` matches 11,338 people nationally and this list names the registries holding the rest; the same query with `"state": "GA"` returns 894 records with `capped: false`.

            '
    ProofBundle:
      type: object
      properties:
        status:
          type: string
          enum:
          - none
          - rendering
          - ready
          description: ready=at least one doc rendered; none=nothing billable rendered.
        billedDocuments:
          type: integer
          description: Count of proof docs billed on this request.
        documents:
          type: array
          items:
            type: object
            properties:
              registry:
                type: string
              format:
                type: string
                enum:
                - pdf
                - html
              url:
                type: string
                description: Fetch via GET /v1/proof-docs/{token}. Empty when the registry has no template yet.
              note:
                type: string
                description: Present (e.g. "no proof template for registry") when the doc was skipped and not billed.
    Address:
      type: object
      properties:
        type:
          type: string
          default: residence
          description: 'Address kind. This is an OPEN vocabulary, not a closed enum — treat it as a string with a common case. Values observed in production, by frequency: `residence` (~85%), `employment` (~8%), `other` (~4%), `incarceration`, `H`, `O`, `school`, `transient`, `RL`. The single-letter values are raw registry codes that are not yet normalized. Match `residence` explicitly and bucket everything else rather than switching exhaustively.

            '
          example: residence
        line1:
          type: string
        city:
          type: string
        county:
          type: string
        state:
          type: string
        zipcode:
          type: string
        lat:
          type: number
          nullable: true
          description: Populated only for GIS/geocoded sources.
        lng:
          type: number
          nullable: true
    SourceStatus:
      type: object
      description: One source's outcome within a search.
      properties:
        source:
          type: string
          description: Juris

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