GuidesAlerts

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

RequestHTTP
POST /your/path HTTP/1.1
Host: ops.northwind.example
Content-Type: application/json
X-Murusai-Signature: t=1789179248,v1=5f1c9a…e2b7
BodyJSON
{
  "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; and hung_up: true when 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.

VerifyNode
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);
});
Answer fast
We wait eight seconds for a 2xx, 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/alerts with delivered.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 type on your side.
  • Any plan. Webhooks and email are on Watch. Texts and calls are Guard.

Every alert type

TypeSeverityWhendata
caller.rate_exceededthreatOne 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_numberscriticalA run of consecutive numbers is working one of yours.number, call_id, pattern, numbers, count, window_min
number.prefix_clusterthreatAn unusual share of traffic sharing a prefix.number, call_id, pattern, count, window_min
number.country_burstthreatA burst from one country.number, call_id, country, count, window_min
number.geo_clusterthreatFresh callers clustering from one small area.number, call_id, zip, city, region, numbers, count, window_min
number.volume_exceededcriticalA number over its hourly ceiling.number, call_id, count
number.volume_spikecriticalA number far above its own baseline for the hour.number, calls, callers, expected
number.volume_normalinfoThe spike ended.number, minutes, peak, expected
number.spend_exceededcriticalA number's spend in the hour past its ceiling.number, spend, ceiling, usual, calls
call.long_runningthreatA call open past its ceiling.number, caller, call_id, started_at, minutes, ceiling_min
call.suspiciouscriticalThe live monitor flagged a call in progress.number, caller, call_id, flags, score, seconds, hung_up
platform.degradedcriticalYour platform answering the carrier with errors or very slowly.number, host, bad, calls, slow_ms, window_min
billing.payment_failedinfoA 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.