Delivery — email & webhooks
Email by default, an optional signed webhook you verify, and the retry contract behind both.
Base URL https://api.offendersearch.appTwo channels
Alerts are delivered to the channels you set on the monitor. Email is the default; a webhook is added when you set webhookUrl. You can set either or both, and at least one is required at creation.
| Channel | Set it with | What arrives |
|---|---|---|
channels.email | A human-readable alert email to the address you name, one per event. | |
| Webhook | channels.webhookUrl | The alert object as a signed JSON POST to your endpoint, one per event. |
Set channels.email to an address and each alert is delivered there as an email. Email is the right channel for a person who reads the alerts; a webhook is the right channel for a system that acts on them.
Signed webhooks
Set channels.webhookUrl and each alert is POSTed to that URL as JSON — the same alert object the history endpoint returns. Respond 2xx promptly to acknowledge. Every delivery is signed so you can verify it came from us: verify the X-Offendersearch-Signature header (an HMAC-SHA256 of the raw request body using your signing secret) before trusting a payload.
import crypto from "node:crypto";
app.post("/hooks/os", express.raw({ type: "*/*" }), (req, res) => {
const sent = req.get("X-Offendersearch-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.OS_WEBHOOK_SECRET)
.update(req.body) // the RAW body, before JSON parsing
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected))) {
return res.sendStatus(400);
}
const alert = JSON.parse(req.body.toString("utf8"));
enqueue(alert.id, alert); // at-least-once: de-duplicate on alert.id
res.sendStatus(200);
});Retries
A webhook that does not answer 2xx is retried with exponential backoff for several attempts, so a brief outage on your side does not lose an alert. Treat delivery as at-least-once: an alert may arrive more than once, so de-duplicate on alert.id. An alert that cannot be delivered after every retry is still readable from GET /v1/monitors/{id}/alerts, so the history endpoint is the backstop for anything a webhook missed.
2xx, then do the work asynchronously. A handler that blocks risks a timeout that looks like a failure and triggers a retry.