NEWPreserve the evidence behind critical screening decisions.Source evidence & screenshots, signed PDFs, independent verification.Learn more
Service Status: All Systems Operational — Safeguarded by our multi-cloud infrastructure.
OpenAPI Spec
View request and response schemas.
Verification Report
See a sample report and open the PDF verifier.
History API
See the separate History API.
All Systems Operational
Uptime: —Avg Response: —
On This Page

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.

EndpointAccessPurpose
POST /sexoffender/report/liveAPI keyCreate an audit-ready Verification Report.
GET /sexoffender/report/liveAPI keyList your account's reports, newest first.
GET /sexoffender/report/live/{reportId}API keyPoll status and retrieve the completed report.
GET /sexoffender/report/live/{reportId}/pdfAPI keyDownload the exact PDF after completion.
GET /sexoffender/report/live/verify/{reportId}PublicGet 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

  1. Search first. Use Extensive Search to screen the person and review returned candidates.
  2. 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.
  3. 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.

POST /sexoffender/report/live
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.

POST /sexoffender/report/live
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.

HTTP/1.1 202 Accepted
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.

GET /sexoffender/report/live/{reportId}
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.

POST https://api.offenders.io/sexoffender/report/live/webhook
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.

GET https://api.offenders.io/sexoffender/report/live/webhook
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.

offenders-live-report-webhook-v1
{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.

OFFENDERS_WEBHOOK_PUBLIC_KEY_FILE=./offenders-webhook-public.pem node receiver.mjs
// 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.

GET /sexoffender/report/live/{reportId}/pdf
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

A failed report remains available through its status endpoint and includes a failure_code and suggested action.

Common report failure codes

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.