# 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.

- **HTML:** https://offendersearch.app/docs/guides/monitoring/email-and-webhook-delivery
- **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

## 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.

> **Verify the signature before you act.** Every delivery carries `X-Offendersearch-Signature: t=<unix seconds>,v1=<hex>`: the HMAC-SHA256, keyed with the monitor's `webhookSecret` (returned once on the `201`), of the string `<t>.<raw body>`. Recompute it over the raw bytes, compare in constant time, refuse a stale `t`, and only then parse the JSON. Rotate the secret with `POST /v1/monitors/{monitorId}/webhook-secret` if it ever leaks.

## 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

**Python (Flask)**
```python
# channels.webhookSecret (whsec_…) was returned once, on the 201 that created the monitor.
import hashlib, hmac, os, time
from flask import Flask, abort, request

SECRET = os.environ["OS_WEBHOOK_SECRET"]

@app.post("/hooks/os")
def hook():
    parts = dict(p.split("=", 1) for p in request.headers.get("X-Offendersearch-Signature", "").split(","))
    body = request.get_data()                      # the RAW bytes, before any JSON parsing
    expected = hmac.new(SECRET.encode(), f"{parts.get('t', '')}.".encode() + body, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts.get("t", "0") or 0)) < 300
    if not (fresh and hmac.compare_digest(parts.get("v1", ""), expected)):
        abort(401)
    alert = request.get_json(force=True)           # type: "monitor.alert"
    enqueue(alert["alertId"], alert)               # de-duplicate on alertId
    return "", 200
```

**JavaScript (Express)**
```javascript
// channels.webhookSecret (whsec_…) was returned once, on the 201 that created the monitor.
import crypto from "node:crypto";
const SECRET = process.env.OS_WEBHOOK_SECRET;        // the monitor's whsec_… secret

app.post("/hooks/os", express.raw({ type: "application/json" }), (req, res) => {
  const header = String(req.get("X-Offendersearch-Signature") ?? "");   // "t=<unix>,v1=<hex>"
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.`).update(req.body).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;   // five-minute replay window
  const ok = fresh && parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const alert = JSON.parse(req.body);           // type: "monitor.alert"
  enqueue(alert.alertId, alert);                // one alert per person per registry: de-duplicate on alertId
  res.sendStatus(200);                          // answer within 10 seconds
});
```

### Create the monitor

```bash
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>*
```json
{
  "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`/`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.

## Related

- [Delivery (reference)](https://offendersearch.app/docs/monitoring/delivery)
- [Webhooks, duplicates & cancelling](https://offendersearch.app/docs/guides/monitoring/webhooks-and-lifecycle)
- [Async & webhooks (platform)](https://offendersearch.app/docs/platform/async-and-webhooks)
- [Monitoring API reference](https://offendersearch.app/docs/monitoring/api-reference)
