Offendersearch
Guide · 12 min · for engineers building trust & safety or continuous-monitoring workflows

How to build an offender alert system that does not cry wolf

Anyone can call a search endpoint on a cron. The hard part is the second run — deciding what counts as new, what counts as changed, and what you must never page a human about twice. This is the part most guides skip.

The shape of the system

Three moving parts: a roster of people you are monitoring, a scheduled search per person, and a diff against what you saw last time. The API supplies the middle one. The other two are yours, and they are where alert quality is won or lost.

Run searches on the Weekly freshness tier for routine monitoring and reserve Daily for the population where currency changes the decision. Freshness is chosen per call, so a single roster can mix both without provisioning two keys.

One monitored person, one call

curl https://api.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": { "firstName": "John", "lastName": "Doe", "dob": "1980-04-12" },
    "freshness": "weekly"
  }'

Store a fingerprint, not the whole record

The naive implementation stores last night's JSON and diffs it whole. It will alert every night forever, because fields that are not about the person still move — a geocoder refines a coordinate, a registry rewrites a description.

Store a fingerprint of the facts you actually care about, keyed on the record identity. Then an alert fires when the fingerprint changes, which is a decision a human recognises: this person moved, this designation changed, this person is newly on the registry.

A fingerprint worth alerting on

import hashlib, json

WATCHED = ("recordId", "housingStatus", "flags", "offense", "addresses")

def fingerprint(rec: dict) -> str:
    """Hash only what a reviewer would consider a real change."""
    subset = {k: rec.get(k) for k in WATCHED}
    # Addresses: keep the place, drop derived coordinates that drift on re-geocode.
    subset["addresses"] = [
        {k: a.get(k) for k in ("type", "line1", "city", "state", "zipcode")}
        for a in (rec.get("addresses") or [])
    ]
    blob = json.dumps(subset, sort_keys=True, default=str)
    return hashlib.sha256(blob.encode()).hexdigest()

The part people lose a week to

Do not fingerprint lastCheckedAt. It changes on every sweep by design — that is what it is for — so including it turns your fingerprint into a clock and every run into an alert.

Decide what a match even is, before you alert on one

A search for a common surname returns many people. If you alert on any hit, your reviewers will stop reading the alerts within a week, and the system is then worse than nothing because it looks like it is working.

Every record carries matchConfidence and matchBasis. Use the basis, not just the score: a hit matched on an exact date of birth is a different kind of evidence from one matched on a birth year, which is different again from a name-only hit with no date at all. Set your alert threshold on the basis you are willing to put in front of a person.

  • Page a human on an exact-DOB match
  • Queue a birth-year match for batch review
  • Log a name-only match, do not page anyone
  • Record matchBasis on the alert so the reviewer sees why it fired

Handle the source being unavailable — without inventing good news

Every response reports per-source status. A search that reached 57 of 58 registries is not the same answer as one that reached all 58, and the difference matters most in exactly the case an alerting system exists for.

Treat an incomplete search as unknown, not as clear. Re-queue it. The failure that destroys trust in a monitoring system is not a missed alert — it is a clean report that was actually a network error.

The part people lose a week to

This is the single most common bug in home-grown monitoring: an empty result set is read as "nobody matched" when it sometimes means "nothing answered". Check the per-source status on every response before you record a negative.

Close the loop with an artifact

When an alert leads to a decision, that decision may be reviewed months later by someone who was not there. Generate the verification report at the moment of the decision and attach it to the case: a timestamped document with a source citation on every record.

This is the difference between "the system flagged them" and a citation to the registry record as it read on the day.

The legal line, stated plainly

Offendersearch is not a consumer reporting agency and results are not a consumer report. Do not use them for FCRA-covered decisions without appropriate process. Some jurisdictions additionally restrict commercial use of their registry data.

Build the workflow so a human makes the adverse decision and the system supplies evidence — which is better engineering regardless of the regulation.

Questions this raises

How often should I re-screen a monitored population?

Weekly is the right default for a standing roster and carries no surcharge; Daily is worth the extra cent per call for the subset where currency changes the outcome. Because freshness is chosen per request, you can escalate an individual to Daily without changing anything about your key.

How do I avoid alerting on the same person forever?

Alert on a change in a fingerprint of the fields you care about, not on the presence of a match. Store the fingerprint per monitored person and compare on each run.

What should happen when a registry is unreachable?

Record the run as incomplete and retry. Every response reports which sources answered, so an incomplete search is distinguishable from a clean one — never collapse the two.

Read the API reference