Monitoring API · v1.0.0

Email and webhook delivery

Email for people, a webhook for systems. Here is exactly what arrives, what the URL must look like, and how retries work.

What you’ll build

A webhook receiver that authenticates each monitor.alert delivery with a token of your own, answers inside the 10-second window, and hands the work off; plus the right email configuration for the people who read alerts. You will also learn how the one-run-a-day cadence drives retries, so you can reconcile instead of waiting.

Before you start

  • Channels are set at creation in channels: email (one address), emails[] (more), webhookUrl. At least one address or a URL is required; both channels at once is fine. Addresses are de-duplicated and returned together as channels.emails.
  • The account owner’s email receives the alert email in addition to the addresses you set.
  • webhookUrl must be https, at most 2,000 characters, on a public host — a fully qualified domain or a public IP literal. localhost, private ranges and internal names are refused with a 422 at creation.
  • On the sandbox the webhook is POSTed immediately at creation (and on test-event); the sandbox sends no email.
Deliveries are not signed today. No signing secret is issued to accounts, so do not reject a delivery for lacking X-Offendersearch-Signature. Authenticate with a long random token in your own webhookUrl (it is stored with the monitor and only ever returned to your account) and refuse any POST that does not carry it. If signing becomes available, the header will be X-Offendersearch-Signature: sha256=<hex HMAC-SHA256 of the raw body>.

Step by step

  1. Mint a token and build the URL. Generate 32+ random bytes, hex-encode them, and put them in the URL: https://your-app.example.com/hooks/offendersearch?token=<hex>. Treat the URL as a secret.
  2. Stand up the receiver. Compare the token in constant time, answer 200 at once, enqueue the body. Do the real work off the request thread.
  3. Create the monitor with both channels. channels: { "emails": ["…"], "webhookUrl": "…" }. On the sandbox the first delivery arrives seconds after the 201.
  4. Reconcile daily. Production evaluates once a day. After each cycle, read GET /v1/monitors/{monitorId}/alerts and compare notifiedAt with what your endpoint logged.

The receiver

import hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
TOKEN = os.environ["OS_HOOK_TOKEN"]          # the token you put in channels.webhookUrl

@app.post("/hooks/offendersearch")
def offendersearch_alert():
    sent = request.args.get("token", "")
    if not hmac.compare_digest(sent, TOKEN):
        abort(404)                           # unknown caller: say nothing
    alert = request.get_json(force=True)     # {"type": "monitor.alert", ...}
    if alert.get("type") != "monitor.alert":
        return "", 200
    enqueue(alert["alertId"], alert["event"], alert)   # de-duplicate on (alertId, event)
    return "", 200                           # answer inside 10 seconds; work happens elsewhere

def enqueue(alert_id, event, body):
    ...

Create the monitor

curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "sex-offender",
    "type": "person",
    "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" },
    "label": "Exact Match — delivery test",
    "channels": {
      "emails": ["alerts@your-app.example.com", "compliance@your-app.example.com"],
      "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=9f2c…e71a"
    }
  }'

What arrives

One request per alert. Headers: Content-Type: application/json, User-Agent: Offendersearch-Monitor/1. Body: the monitor.alert payload; detail is the same record summary the alert history returns as record.

POST <webhookUrl>
{
  "type": "monitor.alert",
  "event": "changed",
  "alertId": "hit_7fa176c8889c7569",
  "monitorId": "mon_b8f40d7afb9dc0d8",
  "monitorLabel": "Exact Match — delivery test",
  "product": "sex-offender",
  "detail": {
    "name": "EXACT MATCH",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "dob_match",
    "matchConfidence": 1.0,
    "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
    "recordId": "rec_ab12cd34ef56ab78cd90"
  },
  "occurredAt": "2026-10-25T14:02:11.602114+00:00",
  "sentAt": "2026-10-26T12:01:03.550091+00:00"
}

The email, by contrast, is a digest: one message per run for each set of recipients, listing every pending alert with its event, the monitor’s label (or the matched name), the city and jurisdiction, and the date. It ends with the notice that alerts are not a consumer report.

Reading the result

FieldValuesWhat to do with it
typemonitor.alertThe only type today. Ignore anything else defensively.
eventnew, changed, removedRoute the work. removed carries a sparse detail (event, jurisdiction, personRef).
alertIdhit_…The alert’s id in GET /v1/monitors/{monitorId}/alerts. De-duplicate on it, per event.
monitorId, monitorLabelmon_…, your label or nullOne endpoint can serve every monitor; monitorLabel is what your operators recognise.
productsex-offender, criminalWhich dataset fired; decides whether detail.recordId or detail.externalId is set.
detailalert recordName, jurisdiction, city, match label, personRef, and recordId / externalId.
occurredAt, sentAtdate-timesWhen the record first matched; when this POST was sent. A redelivery has a new sentAt.

Edge cases and pitfalls

  • Redirects are failures. The POST is sent with redirects disabled; a 301/302 from your URL counts as a failed attempt. Point webhookUrl at the final URL.
  • Only `2xx` counts. A 204 is fine; a 202 is fine; a 3xx/4xx/5xx or no answer within 10 seconds is a failure.
  • One attempt per run, retried next day. There is no backoff and no faster retry: a failed delivery is tried again on the next daily run. An alert is marked delivered (notifiedAt set) as soon as either email or webhook succeeded — so on a monitor with both, a delivered email means the webhook will not be retried. Reconcile against the history.
  • Your host must resolve to public addresses at delivery time, not only at creation; a DNS change to a private address makes deliveries fail with no notice to you.
  • No per-delivery log is exposed. The history endpoint’s notifiedAt is what you have; keep your own receipt log keyed on alertId + sentAt.
  • Email ≤ 254 characters, URL ≤ 2,000 — longer values are a 422 at creation (channels: '<addr>' is not a valid email address., channels.webhookUrl …).
  • Changing channels means a new monitor: there is no update endpoint. Create the replacement, then cancel the old one.

Production checklist

  • Token in the URL, compared in constant time; 404 to unknown callers.
  • Answer 2xx in well under 10 seconds; queue the work.
  • Idempotency on (alertId, event); log sentAt per receipt.
  • A daily reconciliation job over GET /v1/monitors/{monitorId}/alerts for every active monitor.
  • Email recipients who will read a digest; a webhook for systems that act.

Product overview