Skip to main content
Webhooks push the same governance events that drive alerting on the dashboard into your own systems — PagerDuty, a SIEM, a Slack channel, an internal bot — without polling the API. Every delivery is signed with HMAC-SHA256 so a receiver can prove it actually came from Wardin, and delivery is at-least-once with retries: a slow or briefly-down receiver doesn’t silently lose events.

Event types

EventFires when
budget.threshold_reachedA budget’s spend crosses its configured threshold (default 80%) within the current billing period
policy.violationA gateway policy blocks a request — a guardrail rejection, a model-allowlist 403, etc.
anomaly.usage_spikeThe last hour’s request volume is at least 3× the trailing 24-hour baseline (minimum 20 requests, to avoid noise on low-volume tenants)
Each occurrence alerts once — a sustained spike or a budget that stays over threshold doesn’t refire on every evaluation pass, only on the first crossing (per budget period, per violation, or per clock-hour, depending on the event).

Creating an endpoint

curl -X POST https://api.wardin.ai/v1/webhook-endpoints \
  -H "Authorization: Bearer wardin_sk_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Slack #alerts",
    "url": "https://hooks.slack.com/services/T00/B00/xxx",
    "type": "slack",
    "events": ["budget.threshold_reached", "policy.violation"]
  }'
# → { "id", "secret": "whsec_...", ... }
type is required — generic or slack. The response includes a per-endpoint signing secret (whsec_...) — store it; it’s needed to verify deliveries. Full endpoint references: GET /v1/webhook-endpoints, POST /v1/webhook-endpoints, PATCH /v1/webhook-endpoints/{id}, DELETE /v1/webhook-endpoints/{id}.
Endpoint URLs are validated at create/update time and again at delivery time (SSRF guard): private/loopback/link-local addresses and non-HTTPS URLs are rejected in production, and 3xx redirects are never followed. This also blocks DNS rebinding — a hostname that resolves to a public address at config time but a private one at delivery time is caught on the re-check.

Delivery format

A generic endpoint receives the full event envelope:
{
  "id": "delivery-uuid",
  "event": "budget.threshold_reached",
  "created": "2026-07-11T00:00:00.000Z",
  "data": {
    "message": "Budget at 82% ($410.00 of $500.00)",
    "virtual_key_id": "...",
    "spent_usd": 410,
    "budget_usd": 500,
    "threshold_pct": 80
  }
}
A slack endpoint instead receives the native Slack incoming-webhook shape — { "text": "*Wardin · budget.threshold_reached*\n<message>" } — so it renders directly in the channel with no adapter needed. Every POST also carries X-Wardin-Delivery-Id (the delivery’s id — use it to de-duplicate on your side) and X-Wardin-Event (the event type). generic endpoints additionally carry the signature header described below; Slack’s incoming-webhook receiver ignores custom headers, so signed endpoints are generic only.

Verifying the signature

generic deliveries carry an X-Wardin-Signature header in the form t=<unix_seconds>,v1=<hex_hmac> — a timestamp and an HMAC-SHA256 digest of <timestamp>.<raw_body>, keyed with your endpoint’s whsec_... secret. Binding the timestamp into the digest (rather than signing the body alone) lets you reject replayed deliveries outside a tolerance window.
const crypto = require('crypto');

function verifyWardinSignature(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(parts.v1 ?? '');
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Compute the HMAC over the raw, unparsed request body — re-serializing a parsed JSON object can reorder keys or change whitespace and produce a digest mismatch even for a genuine delivery.

Delivery guarantees

Delivery is at-least-once: an event is persisted as a delivery row before any network call is attempted, so a crash between “event happened” and “HTTP POST sent” can never lose it. A failed attempt (non-2xx, timeout, or transport error) is retried with exponential backoff (capped at one hour) until it either succeeds or exhausts the endpoint’s retry budget, at which point it’s dead-lettered — the endpoint’s status still reflects the last outcome, but that specific delivery stops retrying. Because delivery is at-least-once, not exactly-once, treat X-Wardin-Delivery-Id as an idempotency key if your receiver can’t tolerate a duplicate.

Test send

Send a synthetic webhook.test event to one endpoint and get an immediate pass/fail result — useful for confirming a new endpoint and secret are wired correctly before relying on a real event:
curl -X POST https://api.wardin.ai/v1/webhook-endpoints/ENDPOINT_ID/test \
  -H "Authorization: Bearer wardin_sk_ADMIN_KEY"
# → { "delivered": true, "error": null }
Full endpoint reference: POST /v1/webhook-endpoints/{id}/test.

Where to see it

Console → GATEWAY → Webhooks lists every endpoint (with a live delivered/failed status dot), lets you add/toggle/delete endpoints and reveal a secret, and includes the same test-send action as the API.