# Integration recipes

> Everything required for a working implementation, in one place per task. Fictional names; run against the sandbox first.

- **HTML:** https://offendersearch.app/docs/ai/recipes
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## Build a sex offender screening integration (Python)

```python
# Sex offender screening — Python 3.11+, requests. Env: OFFENDERSEARCH_API_KEY, OFFENDERSEARCH_BASE
import os, time, requests

API_KEY = os.environ["OFFENDERSEARCH_API_KEY"]
BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://api.offendersearch.app")
H = {"X-API-Key": API_KEY, "Content-Type": "application/json"}

def search(first: str, last: str, dob: str | None = None, state: str | None = None) -> dict:
    body = {"query": {"firstName": first, "lastName": last, **({"dob": dob} if dob else {})}}
    if state:
        body["jurisdictions"] = [state]
    for attempt in range(4):
        r = requests.post(f"{BASE}/v1/search", headers=H, json=body, timeout=60)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5"))); continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt); continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError("unavailable")

def classify(data: dict) -> str:
    if not data["records"]:
        return "clear" if data["status"] == "complete" else "incomplete"
    strong = [x for x in data["records"] if x.get("matchState") == "dob_match"]  # DOB corroborated; gate on matchState, not on matchConfidence alone
    return "match" if strong else "review"

if __name__ == "__main__":
    data = search("Jordan", "Rivera", "1988-04-12")
    print(classify(data), data["searchId"], [s["source"] for s in data["sourceStatus"] if s["incomplete"]])
```

Fields read: `status`, `searchId`, `sourceStatus[].source` / `.incomplete`, `records[].matchState` / `.matchConfidence`. Reference: [POST /v1/search](/docs/search).

## Integrate criminal search into a Node.js application

```javascript
// Criminal search in a Node.js service — Node 18+. Env: OFFENDERSEARCH_API_KEY, OFFENDERSEARCH_BASE
const API_KEY = process.env.OFFENDERSEARCH_API_KEY;
const BASE = process.env.OFFENDERSEARCH_BASE ?? "https://api.offendersearch.app";
const H = { "X-API-Key": API_KEY, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function criminalSearch({ firstName, lastName, dob, state, page = 1 }) {
  const body = { query: { lastName, firstName, ...(dob ? { dob } : {}), ...(state ? { state } : {}) }, page, perPage: 50 };
  let r = await fetch(`${BASE}/v1/criminal/search`, { method: "POST", headers: H, body: JSON.stringify(body) });
  if (r.status === 429) { await sleep(1000 * Number(r.headers.get("Retry-After") ?? 5)); return criminalSearch({ firstName, lastName, dob, state, page }); }
  if (r.status === 402) throw new Error("trial used up or no active subscription");   // body: { detail }
  if (r.status === 422) throw new Error(`refused: ${JSON.stringify((await r.json()).error)}`);  // { error: { code, message } }
  if (r.status === 202) {                      // more than two live sources: the job runs in the background
    const { poll } = await r.json();           // "/v1/criminal/searches/crs_…"
    for (let i = 0; i < 150; i++) {            // every 2 s, up to 5 minutes
      await sleep(2000);
      const p = await fetch(`${BASE}${poll}`, { headers: H });
      if (p.status === 202) continue;          // JobRecord, status "pending", result null
      if (p.status === 422) throw new Error("refused when run: the stored query was too broad");
      if (!p.ok) throw new Error(`poll HTTP ${p.status}`);
      return (await p.json()).result;          // 200: JobRecord — the envelope is under result
    }
    throw new Error("live check did not finish");
  }
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  return r.json();
}

export function summarise(data) {
  // Each record: recordType, currentStatus, cases[].charges[] with description/statute/severity/disposition.
  return data.records.map((rec) => ({
    type: rec.recordType,
    status: rec.currentStatus,
    confidence: rec.matchConfidence,
    charges: (rec.cases ?? []).flatMap((c) => (c.charges ?? []).map((ch) => ({
      description: ch.description, statute: ch.statute, severity: ch.severity, disposition: ch.disposition,
    }))),
    source: rec.sources?.[0]?.code,
  }));
}
```

Fields read: `recordType`, `currentStatus`, `matchConfidence`, `cases[].charges[]` (`description`, `statute`, `severity`, `disposition`), `sources[].code`. Reference: [POST /v1/criminal/search](/docs/criminal/search); meanings: [Understanding charges](/docs/guides/criminal/charges).

## Integrate criminal search into a Django backend

```python
# screening/services.py — Django 4+/5. Settings: OFFENDERSEARCH_API_KEY, OFFENDERSEARCH_BASE
import time, requests
from django.conf import settings

H = {"X-API-Key": settings.OFFENDERSEARCH_API_KEY, "Content-Type": "application/json"}
BASE = getattr(settings, "OFFENDERSEARCH_BASE", "https://api.offendersearch.app")

class ScreeningError(Exception):
    pass

def criminal_search(*, last_name: str, first_name: str = "", dob: str | None = None, state: str | None = None) -> dict:
    query = {"lastName": last_name}
    if first_name: query["firstName"] = first_name
    if dob: query["dob"] = dob
    if state: query["state"] = state
    for attempt in range(4):
        r = requests.post(f"{BASE}/v1/criminal/search", headers=H, json={"query": query, "perPage": 50}, timeout=60)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5"))); continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt); continue
        if r.status_code == 402: raise ScreeningError("trial used up or no active subscription")  # body: {"detail": ...}
        if r.status_code == 422: raise ScreeningError(f"refused: {r.json()['error']}")  # {"error": {"code", "message"}}
        if r.status_code == 202:  # more than two live sources: the job runs in the background
            poll = r.json()["poll"]  # "/v1/criminal/searches/crs_…"
            for _ in range(150):  # every 2 s, up to 5 minutes
                time.sleep(2)
                p = requests.get(f"{BASE}{poll}", headers=H, timeout=60)
                if p.status_code == 202: continue  # JobRecord, status "pending", result null
                if p.status_code == 422: raise ScreeningError("refused when run: the stored query was too broad")
                p.raise_for_status()
                return p.json()["result"]  # 200: JobRecord — the envelope is under result
            raise ScreeningError("live check did not finish")
        r.raise_for_status()
        return r.json()
    raise ScreeningError("unavailable")

# screening/views.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST
import json

@require_POST
def screen(request):
    body = json.loads(request.body)
    data = criminal_search(last_name=body["lastName"], first_name=body.get("firstName", ""),
                           dob=body.get("dob"), state=body.get("state"))
    # Persist data["searchId"], data["status"], data["counts"] with your subject before responding.
    return JsonResponse({"searchId": data["searchId"], "status": data["status"],
                         "records": data["counts"]["records"], "incomplete": data["counts"]["sourcesIncomplete"]})
```

## Build an admissions screening workflow

The complete admissions workflow — both searches, outcome classification, audit record, HIPAA notes — is the [skilled nursing guide](/docs/solutions/skilled-nursing). Its Python and JavaScript modules are production-shaped and run as written.

## Configure monitoring notifications

```bash
# Create a person watch delivered by webhook (also by email if you add channels.email)
curl -X POST https://api.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "product": "sex-offender",
    "type": "person",
    "label": "Jordan Rivera — resident 48213",
    "person": { "firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12" },
    "channels": { "webhookUrl": "https://example.com/hooks/offendersearch?token=YOUR_SECRET" }
  }'

# A monitor watches one dataset: repeat with "product": "criminal" to watch criminal records too.

# Read alert history
curl https://api.offendersearch.app/v1/monitors/MONITOR_ID/alerts -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
```

Exact request fields, the Monitor object and alert events are in the [Monitoring reference](/docs/monitoring/api-reference); delivery, signing status and retries in [Delivery](/docs/monitoring/delivery). Put a secret token in your webhook URL and verify it on receipt.

## Integrate background screening into an existing SaaS application

1. Add `OFFENDERSEARCH_API_KEY` and `OFFENDERSEARCH_BASE` to your secret configuration; point the base at the sandbox in non-production.
2. Wrap the two searches as a service module (the Python or Node recipe above) with the retry policy and outcome classification.
3. Send `subjectId` (your person id) and `clientId` (your tenant id) on every search so clearances stay per tenant. [Clearances](/docs/clearances).
4. Store `searchId`, `status`, `counts` and `sourceStatus[]` with your subject; render `POST /v1/report` on demand.
5. Reconcile usage from `meterEvents[]` where a response carries it (criminal searches, live sources, documents) and from [Dashboard → Usage](/dashboard/usage) for Regular Sex Offender searches, which report no `meterEvents`.
6. Add a Monitoring person watch per active subject and a webhook receiver; cancel on offboarding.
7. If your product makes employment, housing, credit or insurance decisions, stop here and read the [FCRA Partner API](/docs/fcra): that is the regulated path.

## Related

- [Coding assistants](https://offendersearch.app/docs/ai/coding-assistants)
- [Skilled nursing admissions](https://offendersearch.app/docs/solutions/skilled-nursing)
