openapi: 3.2.0 info: title: Offendersearch Support 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 `) 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 `, or `?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: Support paths: /v1/support: post: operationId: submitSupportMessage summary: Send a message to the Offendersearch team description: 'Sends a message to our support team. **No API key required** — this is the endpoint behind the contact form on offendersearch.app, and it is open because the people who need it most are the ones who do not have a key yet. A `200` means the message is **recorded**: it is stored against the `submissionId` in the response and can be looked up by that id if you ever need to refer to it. It does not report that an email has arrived anywhere, because that is a separate step we do not make you wait for. Replies come from the address in `supportEmail`, normally within one business day. You can also write to that address directly; both routes reach the same inbox. The endpoint accepts `application/json` or `application/x-www-form-urlencoded`, so an ordinary HTML form can post to it without any JavaScript. ' security: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SupportRequest' application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/SupportRequest' responses: '200': description: The message was recorded. content: application/json: schema: $ref: '#/components/schemas/SupportResult' '413': description: 'The body is larger than this endpoint accepts (64 KB). Send a summary and reply to our answer with anything bulky. ' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: 'A required field is missing or the email address is not a valid address. Nothing was stored; correct it and send again. ' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: 'Too many messages from one address in a short period. Nothing was stored, so nothing is duplicated when you retry. `Retry-After` gives the number of seconds to wait, and the same value is in `detail.retryAfterSeconds`. ' headers: Retry-After: description: Seconds to wait before sending the message again. schema: type: integer content: application/json: schema: $ref: '#/components/schemas/RateLimitError' '503': description: 'The message could not be recorded, so **do not treat it as sent**. Use the address in the error text instead; it is answered by the same team. ' content: application/json: schema: $ref: '#/components/schemas/Error' tags: - Support components: schemas: RateLimitError: type: object description: 'A refusal that will succeed if you wait. `detail.retryAfterSeconds` always equals the `Retry-After` header — read either, but read one: retrying immediately will simply be refused again. ' properties: detail: type: object properties: code: type: string enum: - rate_limited - overloaded description: '`rate_limited` is a per-caller quota and clears by itself. `overloaded` means the service is at capacity. ' message: type: string retryAfterSeconds: type: integer description: Seconds to wait before retrying. Same as `Retry-After`. SupportRequest: type: object required: - name - email - message properties: name: type: string maxLength: 120 description: Who we are replying to. example: Dana Okafor email: type: string maxLength: 254 description: 'The address we reply to. It is checked for a valid shape only — we do not send anything to it to confirm it, and it is never added to a list. ' example: dana@example.org message: type: string minLength: 10 maxLength: 8000 description: What you would like to ask or tell us. subject: type: string maxLength: 200 description: Optional one-line summary. topic: type: string enum: - sales - support - security - baa - other default: support description: 'Which team should read it first. Anything unrecognised is treated as `support`, so a wrong value routes the message rather than refusing it. ' website: type: string description: 'Leave empty. Present so automated submissions can be told apart from people; a message that fills it in is still recorded. ' renderedAt: type: integer format: int64 description: 'Optional. Unix time in milliseconds when the form was displayed. Omit it if you are calling the endpoint directly — its absence is not held against a submission. ' SupportResult: type: object properties: status: type: string enum: - received description: '`received` means the message is recorded and retrievable by `submissionId`. ' submissionId: type: string description: 'The reference for this message. Quote it if you follow up about the same thread. ' example: sup_9f2c1a4b6d8e0f31 supportEmail: type: string description: 'The address a reply will come from, and the address you can write to directly instead of using this endpoint. ' example: support@offendersearch.app 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. securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key description: Customer API key. Primary auth for search/records/compat/proof. BearerAuth: type: http scheme: bearer description: 'Signed session token (HMAC-SHA256). Auth for account/dashboard endpoints; also accepted by POST /v1/search and the compat endpoint. ' QueryKeyAuth: type: apiKey in: query name: key description: API key passed as `?key=` — offenders.io demo mode (compat endpoint only). AdminAuth: type: apiKey in: header name: X-Admin-Key description: Internal admin credential — separate from customer API keys.