View request and response schemas.Verification Report
See a sample report and open the PDF verifier.History API
See the separate History API.
How the Verification Report API works
Create a report to save the matches, source checks, and results from a search. A report can also show that the search found no matches. Each request gets a new Report ID. The API returns immediately; check its status, then open the JSON report or PDF.
The PDF includes the Report ID and a link where someone can check whether their PDF matches the file Offenders.io issued.
| Endpoint | Access | Purpose |
|---|---|---|
POST /sexoffender/report/live | API key | Create an audit-ready Verification Report. |
GET /sexoffender/report/live | API key | List your account's reports, newest first. |
GET /sexoffender/report/live/{reportId} | API key | Poll status and retrieve the completed report. |
GET /sexoffender/report/live/{reportId}/pdf | API key | Download the exact PDF after completion. |
GET /sexoffender/report/live/verify/{reportId} | Public | Get a minimal signed verification receipt. |
Base URL: https://api.offenders.io. Send your API key in X-API-Key or Authorization: Bearer for private endpoints. The public verification receipt does not include the report or PDF.
Recommended screening workflow
- Search first. Use Extensive Search to screen the person and review returned candidates.
- Resolve the screening result. No confirmed match: use the same supported identity criteria for the report, such as name, DOB, and state. Confirmed match: use that record's UUID to target the specific person you identified.
- Preserve the evidence. The Verification Report performs live source checks and preserves the result, source status, timestamps, and available official-source evidence in a signed PDF.
Verification Reports document a screening decision; they do not replace candidate review and identity resolution.
Create a report
Send JSON with either a Search record uuid, or firstName and lastName. Name searches can also include dob, state, city, zipcode, or address. Do not mix a UUID with name fields.
DOB is highly recommended. A name-based report without dob always returns inconclusive, even when no candidates are found. A date of birth is needed to assess a candidate match. For a UUID report, the selected record supplies the comparison information.
For name-based reports, state includes records from that state's registry or with a reported residence in that state. A Florida registry record with a California address can appear with either state: "FL" or state: "CA". If either state is missing or unrecognized, the record can still qualify through the other state.
You can add notes in request_metadata: purpose (up to 500 characters), reference (255), and requested_by (255). These are details you supply; Offenders.io does not verify them. They stay in the private report. Set include_in_pdf to true to also show them in the Request Reference section of the PDF. It defaults to false.
No-match report — use the search criteria
When your screening search returns no confirmed match, generate the report using the same supported identity criteria used for that screening.
Content-Type: application/json
X-API-Key: {YOUR_API_KEY}
{
"firstName": "Jane",
"lastName": "Example",
"dob": "1990-04-12",
"state": "CA",
"request_metadata": {
"purpose": "Annual audit",
"reference": "CASE-123",
"requested_by": "Facility A",
"include_in_pdf": false
}
}
Confirmed-match report — use the record UUID
When you have reviewed the search results and confirmed a specific match, use that record's UUID rather than running another broad name report.
Content-Type: application/json
X-API-Key: {YOUR_API_KEY}
{
"uuid": "5893a310-37ce-58d8-b101-39e76a60e94a"
}
The API returns 202 Accepted with a Report ID. The report continues generating in the background.
Content-Type: application/json
{
"report_id": "lr_0123456789abcdef0123456789abcdef",
"status": "queued",
"resource_uri": "/sexoffender/report/live/lr_0123456789abcdef0123456789abcdef"
}
Poll status and list reports
Use resource_uri to check whether the report is queued, running, completed, or failed. When complete, the same URL returns the final conclusion, full snapshot, signed_manifest, file hashes, pdf_uri, and any request_metadata you supplied.
X-API-Key: {YOUR_API_KEY}
{
"report_id": "lr_...",
"status": "completed",
"conclusion": "match_identified",
"created_at": "2026-09-25T17:00:00.000Z",
"completed_at": "2026-09-25T17:00:18.000Z",
"snapshot_sha256": "...",
"pdf_sha256": "...",
"pdf_uri": "/sexoffender/report/live/lr_.../pdf",
"request_metadata": { "purpose": "Annual audit", "include_in_pdf": false },
"snapshot": { "conclusion": "match_identified", "candidates": [ ... ], "source_coverage": { ... } },
"signed_manifest": { ... }
}
conclusion gives the same result as the PDF: match_identified (Match Identified), no_match_identified (No Match Identified), or inconclusive (the available records could not confirm a result). It is also saved in snapshot.conclusion. Read the report for the reason when a result is Inconclusive. Pending and failed responses omit this field; earlier reports may also omit it.
GET /sexoffender/report/live?limit=10 lists your reports, newest first. Set limit from 1 to 20; the default is 10. If the response has next_cursor, pass it as cursor to get the next page. The list shows status and dates; open a report for its full results.
Polling is enough to integrate. You can also configure a webhook to notify your app when a report is ready; it does not include the report contents.
Report webhooks
Use a webhook to notify your app when a report is ready, then retrieve the report with your API key. Polling remains available.
In Dashboard, open Verification Reports → Settings. Save your endpoint URL, then use the On/Off switch to control notification delivery. Turning it off keeps your saved endpoint URL and stops Webhook deliveries while notifications are off.
1. Save your Webhook endpoint
Use POST to save your callback URL and turn notifications on or off. Use a public HTTPS URL on port 443 without credentials or a fragment. Redirects are not followed. Keep API keys and personal information out of the URL.
For your first configuration, use revision: 0. If settings were already saved through the API or Dashboard, use the current revision returned by GET in the next section.
Content-Type: application/json
X-API-Key: {YOUR_API_KEY}
{
"url": "https://example.com/webhooks/offenders",
"active": true,
"revision": 0
}
A successful save returns the saved settings and an updated revision. Keep that revision for the next change. It prevents a save from overwriting a more recent change. If you receive 409 settings_changed, read the current settings with GET, review them and use that revision when saving again.
2. Read or confirm your current settings
Use GET to read this account’s saved configuration, or to confirm a successful save. The example below shows the settings after the first POST above.
X-API-Key: {YOUR_API_KEY}
HTTP/1.1 200 OK
{
"url": "https://example.com/webhooks/offenders",
"active": true,
"revision": 1,
"public_key_pem": "{YOUR_WEBHOOK_PUBLIC_KEY_PEM}",
"signature_header": "X-Offenders-Webhook-Signature"
}
Use public_key_pem from your account settings to verify Webhook signatures. Signature verification is optional.
To turn notifications off through the API, POST the saved URL, active: false and the current revision. The URL remains saved. You may set url: null if you also want to remove it.
Changing the endpoint sends pending notifications and retries to the new address. Disabling notifications stops pending deliveries. Enabling notifications does not replay earlier reports.
Receive a notification
The callback is a JSON POST with type live_report.completed. It identifies the completed Verification Report using the format below.
"id": "event_89abcdef0123456789abcdef01234567",
"type": "live_report.completed",
"created_at": "2026-10-25T12:00:00.000Z",
"report_id": "lr_0123456789abcdef0123456789abcdef",
"status": "completed",
"resource_uri": "/sexoffender/report/live/lr_0123456789abcdef0123456789abcdef"
}
Notifications contain no applicant information, report results or PDF. Use resource_uri with your API key to read the current report. Return a 2xx response to acknowledge the callback. Failed callbacks get up to 10 total attempts: the first retry is after two minutes, with increasing intervals capped at one hour. The event id stays the same across retries; use it to avoid processing the same event twice.
Optional signature verification
Signature verification is optional. Use the public key from your account settings to verify Webhook signatures. See the sample implementation below.
Signature verification details
To verify a notification, read X-Offenders-Webhook-Signature as t={unix_seconds},id={event_id},v1={signature}. Verify the base64url v1 value using RS256 (RSA-SHA256 with PKCS#1 v1.5) and the public_key_pem from authenticated settings. Verify the exact raw request body before parsing or changing it.
{t}
{id}
{raw_request_body}
Separate the four parts above with newline characters. Check that the header event ID matches the body id and reject stale timestamps; a five-minute tolerance is recommended. Keep the trusted public key in your receiver configuration and deduplicate verified event IDs.
Webhook sample implementation · Optional signature verification
Use this example if you want to verify signatures. Save this example as receiver.mjs and the verification public key from Dashboard as offenders-webhook-public.pem. Use Node.js 22 or later and replace the ./report-ready.mjs import with your existing report-ready handler. Route your public HTTPS endpoint to /webhooks/offenders. No npm packages are required for this receiver.
// receiver.mjs - Node.js 22+; no npm packages required.
// Save the Dashboard verification public key as offenders-webhook-public.pem.
// Connect handleReportReady from your existing application below.
// Expose /webhooks/offenders through your HTTPS reverse proxy.
import http from 'node:http';
import { createPublicKey, verify } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const MAX_BODY_BYTES = 64 * 1024;
const SIGNATURE_HEADER = /^t=([1-9]\d{0,11}),id=(event_[a-f0-9]{32}),v1=([A-Za-z0-9_-]+)$/;
export function verifyEvent(rawBody, signatureHeader, publicKey, now = Date.now()) {
const parts = typeof signatureHeader === 'string' && SIGNATURE_HEADER.exec(signatureHeader);
if (!parts || Math.abs(Math.floor(now / 1000) - Number(parts[1])) > 300) {
throw new Error('Invalid or expired signature');
}
const [, timestamp, eventId, encodedSignature] = parts;
const signedBytes = Buffer.concat([
Buffer.from('offenders-live-report-webhook-v1\n' + timestamp + '\n' + eventId + '\n'),
rawBody,
]);
const signature = Buffer.from(encodedSignature, 'base64url');
if (signature.toString('base64url') !== encodedSignature ||
!verify('RSA-SHA256', signedBytes, publicKey, signature)) {
throw new Error('Invalid signature');
}
const event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
const reportId = typeof event?.report_id === 'string' ? event.report_id : '';
if (!/^lr_[a-f0-9]{32}$/.test(reportId)) throw new Error('Invalid report ID');
const reportPath = new RegExp('^/sexoffender/report/live/' + reportId + '$');
if (event?.id !== eventId ||
event.type !== 'live_report.completed' || event.status !== 'completed' ||
(typeof event.resource_uri !== 'string' || !reportPath.test(event.resource_uri)) ||
!/^lr_[a-f0-9]{32}$/.test(reportId)) {
throw new Error('Invalid event');
}
return event;
}
export function createWebhookReceiver({ publicKey, onReportReady }) {
if (typeof onReportReady !== 'function') throw new Error('Supply your report-ready handler');
const key = createPublicKey(publicKey);
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 2048) {
throw new Error('Use the RSA public key from your Dashboard webhook settings');
}
const server = http.createServer(async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'POST' || req.url !== '/webhooks/offenders') {
res.writeHead(404).end();
return;
}
if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) {
res.writeHead(415).end();
return;
}
const chunks = [];
let size = 0;
try {
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) { res.writeHead(413).end(); return; }
chunks.push(chunk);
}
} catch { res.writeHead(400).end(); return; }
const rawBody = Buffer.concat(chunks);
let event;
try { event = verifyEvent(rawBody, req.headers['x-offenders-webhook-signature'], key); }
catch { res.writeHead(401).end(); return; }
try {
// Your handler uses event.id to deduplicate with your existing storage.
// Resolve after processing succeeds; throw to request a delivery retry.
await onReportReady(event);
res.writeHead(204).end();
} catch { res.writeHead(503).end(); }
});
server.requestTimeout = 15000;
server.headersTimeout = 10000;
return server;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const publicKey = readFileSync(process.env.OFFENDERS_WEBHOOK_PUBLIC_KEY_FILE, 'utf8');
const { handleReportReady } = await import('./report-ready.mjs');
const server = createWebhookReceiver({ publicKey, onReportReady: handleReportReady });
server.listen(Number(process.env.PORT || 3000), '127.0.0.1');
}
// report-ready.mjs exports your async handleReportReady(event) function.
// Use event.id to deduplicate in your existing application/storage. Retrieve
// the report at event.resource_uri with your API key and process its result.
// Resolve when your work succeeds. Keep API keys and report contents out of logs.
The receiver verifies the signature, calls your handler and returns 204 when it succeeds. Use event.id with your existing storage to avoid processing retries twice. Retrieve the report using your API key when your application needs it.
Settings errors use a string code and message: 400 for invalid settings, query inputs, ambiguous credentials or an invalid endpoint; 401 for missing or inactive credentials; 403 for unavailable account or workflow access; 405 for unsupported methods; 409 when the revision changed; and 413 when the JSON request exceeds 4096 bytes. Use GET to read settings and POST to save them. An invalid endpoint returns invalid_webhook_url; check that it is a public HTTPS URL.
Download the PDF
When the report is complete, open pdf_uri with your API key. It returns application/pdf for browser viewing or download. Save the file without changing it if you want others to verify its hash. If the PDF is not ready, the endpoint returns 409.
X-API-Key: {YOUR_API_KEY}
Accept: application/pdf
The PDF URL requires your API key. The QR code opens a public verification page, not the PDF.
Understand the evidence
sources.nsopw and sources.state tell you what happened during the live checks. attempted_verified means that source found the record. attempted_not_found means the check ran but did not find it. attempted_unavailable means the source could not be checked.
not_attempted_budget means the live source check was not attempted for that candidate because the report exceeded the supported verification scope. Earlier saved records may still appear even when a live check does not find them. If a search is too broad to include all records in the Offenders.io database search, the report fails with indexed_scope_too_large; narrow the search and create another report.
field_provenance shows whether a field came from a saved record, NSOPW, or a state registry. Use sources for the result and time of each live check.
Public PDF verification
The public endpoint returns a signed receipt with the Report ID, issue time, and PDF hash. It does not return the report, customer details, or PDF file. Only completed reports have a receipt.
To check a PDF, open the PDF verification tool and choose the file. The browser checks the file against the signed receipt without uploading it. Scanning the QR code confirms the Report ID was issued; checking the file confirms whether that PDF matches it.
Retention
Verification Reports and their associated evidence are retained for seven years by default.
Errors
Errors include a code and message; many also include a suggested action.
Common HTTP status codes
400 Bad Request— Invalid or conflicting input.401 Unauthorized— Missing or invalid API key.403 Forbidden— The API key is not eligible to use Verification Reports.404 Not Found— The report does not exist or belongs to another account.405 Method Not Allowed— The endpoint does not support the requested HTTP method.409 Conflict— The PDF is not ready yet.429 Too Many Requests— Rate limit exceeded.500 Internal Server Error— An unexpected error occurred.503 Service Unavailable— The service is temporarily unavailable.
A failed report remains available through its status endpoint and includes a failure_code and suggested action.
Common report failure codes
indexed_scope_too_large— Narrow the search and create a new report.uuid_not_found— Check the UUID and try again.
Access and privacy
Creating, listing, retrieving, and downloading Verification Reports requires an eligible production API key. Testing keys cannot create or retrieve reports.
Public verification exposes only the signed verification receipt. It does not expose the report contents, customer details, or PDF.
Billing
Reports are billed only when they successfully complete. Failed reports are not charged.
Status checks, PDF downloads, and public verification do not incur additional report charges.
No separate Search or Extensive API usage charges are added for report generation.