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 aschannels.emails. - The account owner’s email receives the alert email in addition to the addresses you set.
webhookUrlmust behttps, 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 a422at creation.- On the sandbox the webhook is POSTed immediately at creation (and on
test-event); the sandbox sends no email.
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
- 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. - Stand up the receiver. Compare the token in constant time, answer
200at once, enqueue the body. Do the real work off the request thread. - Create the monitor with both channels.
channels: { "emails": ["…"], "webhookUrl": "…" }. On the sandbox the first delivery arrives seconds after the201. - Reconcile daily. Production evaluates once a day. After each cycle, read
GET /v1/monitors/{monitorId}/alertsand comparenotifiedAtwith 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.
{
"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
| Field | Values | What to do with it |
|---|---|---|
type | monitor.alert | The only type today. Ignore anything else defensively. |
event | new, changed, removed | Route the work. removed carries a sparse detail (event, jurisdiction, personRef). |
alertId | hit_… | The alert’s id in GET /v1/monitors/{monitorId}/alerts. De-duplicate on it, per event. |
monitorId, monitorLabel | mon_…, your label or null | One endpoint can serve every monitor; monitorLabel is what your operators recognise. |
product | sex-offender, criminal | Which dataset fired; decides whether detail.recordId or detail.externalId is set. |
detail | alert record | Name, jurisdiction, city, match label, personRef, and recordId / externalId. |
occurredAt, sentAt | date-times | When 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/302from your URL counts as a failed attempt. PointwebhookUrlat the final URL. - Only `2xx` counts. A
204is fine; a202is fine; a3xx/4xx/5xxor 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 (
notifiedAtset) 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
notifiedAtis what you have; keep your own receipt log keyed onalertId+sentAt. - Email ≤ 254 characters, URL ≤ 2,000 — longer values are a
422at 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;
404to unknown callers. - Answer
2xxin well under 10 seconds; queue the work. - Idempotency on (
alertId,event); logsentAtper receipt. - A daily reconciliation job over
GET /v1/monitors/{monitorId}/alertsfor every active monitor. - Email recipients who will read a digest; a webhook for systems that act.