Webhooks
Every alert, as a signed POST to a URL you choose, the moment it is raised. This is how alerts reach your own systems: a Slack bot, an on-call rota, a runbook.
Setting it up
Settings → Alerts → Webhook, or PATCH /v1/account with alert_webhook_url. The URL must be https. The signing secret is beside it in the dashboard, shown once you ask for it; it never changes unless you rotate it there.
What arrives
POST /your/path HTTP/1.1
Host: ops.northwind.example
Content-Type: application/json
X-Murusai-Signature: t=1789179248,v1=5f1c9a…e2b7{
"id": "b1f2c8d0-4e1a-4c7e-9a3b-2f6d8e9c1a55",
"type": "caller.rotating_numbers",
"account_id": "6a1c0f2e-…",
"created_at": "2026-09-21T02:14:08.000Z",
"data": {
"number": "+14155550100",
"call_id": "CA7f3e2a91c04b",
"pattern": "+151255501",
"numbers": 5,
"count": 7,
"window_min": 15,
"prior": { "score": 71, "days": 2 },
"hung_up": true
}
}- idstring
- Unique per alert. Use it to drop a delivery you have already handled.
- typestring
- One of the types below.
- account_idstring
- Yours. Useful when one endpoint serves several accounts.
- created_atstring
- When the alert was raised.
- dataobject
- What it is about. The fields depend on the type, listed below. Three appear where they apply:
direction: "outbound"when the alert is about calls placed from your number;prior, what the caller scored last time and how many days ago, when it had a record; andhung_up: truewhen Guard ended the call.
Verifying it came from us
The X-Murusai-Signature header carries a timestamp and an HMAC-SHA256 of {timestamp}.{raw body} under your secret. Compute the same, compare in constant time, and refuse anything older than a few minutes. Hash the body as received; re-serialising the JSON changes the bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body as bytes, exactly as received.
export function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
if (!parts.t || !parts.v1) return false;
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return fresh && a.length === b.length && timingSafeEqual(a, b);
}
// Express: keep the raw body, verify, then parse.
app.post("/murusai", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.header("x-murusai-signature") ?? "", process.env.MURUSAI_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const alert = JSON.parse(req.body);
res.status(200).end(); // answer first; do the work after
handle(alert);
});import hmac, hashlib, time
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
if "t" not in parts or "v1" not in parts:
return False
fresh = abs(time.time() - int(parts["t"])) < 300
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return fresh and hmac.compare_digest(expected, parts["v1"])
# Flask
@app.post("/murusai")
def murusai():
if not verify(request.get_data(), request.headers.get("X-Murusai-Signature", ""), WEBHOOK_SECRET):
return "", 401
alert = request.get_json()
handle_later(alert) # queue it; answer now
return "", 2002xx, then give up. Acknowledge first and do the work after; an endpoint that pages a human and then answers is an endpoint that times out.Delivery
- Once. One attempt per alert, no retries. If your endpoint was down, the alert is still in the dashboard and in
GET /v1/alertswithdelivered.webhook: false, so a sweep of that endpoint on recovery misses nothing. - Out of band. Delivery never blocks the call it is about. A slow endpoint slows nothing but itself.
- Cooled down. One alert per type per subject per fifteen minutes. A burst of five hundred calls from one run of numbers is one
caller.rotating_numbers, not five hundred. - Every severity. The webhook gets everything; the floors apply to the other channels. Filter on
typeon your side. - Any plan. Webhooks and email are on Watch. Texts and calls are Guard.
Every alert type
| Type | Severity | When | data |
|---|---|---|---|
| caller.rate_exceeded | threat | One number called one of yours too often, or failed the gate. | number, caller, call_id, count, window_min · gate: "failed" when that was it |
| caller.rotating_numbers | critical | A run of consecutive numbers is working one of yours. | number, call_id, pattern, numbers, count, window_min |
| number.prefix_cluster | threat | An unusual share of traffic sharing a prefix. | number, call_id, pattern, count, window_min |
| number.country_burst | threat | A burst from one country. | number, call_id, country, count, window_min |
| number.geo_cluster | threat | Fresh callers clustering from one small area. | number, call_id, zip, city, region, numbers, count, window_min |
| number.volume_exceeded | critical | A number over its hourly ceiling. | number, call_id, count |
| number.volume_spike | critical | A number far above its own baseline for the hour. | number, calls, callers, expected |
| number.volume_normal | info | The spike ended. | number, minutes, peak, expected |
| number.spend_exceeded | critical | A number's spend in the hour past its ceiling. | number, spend, ceiling, usual, calls |
| call.long_running | threat | A call open past its ceiling. | number, caller, call_id, started_at, minutes, ceiling_min |
| call.suspicious | critical | The live monitor flagged a call in progress. | number, caller, call_id, flags, score, seconds, hung_up |
| platform.degraded | critical | Your platform answering the carrier with errors or very slowly. | number, host, bad, calls, slow_ms, window_min |
| billing.payment_failed | info | A payment for Guard did not go through. | amount_cents, attempt |
Severity is fixed per type and is what the email, text and voice floors are measured against. New types are added from time to time; an endpoint that switches on type should have a default branch.