anoman
Sign InGet API Key
Documentation home

Asynchronous events you don't want to poll for.

HMAC-signed POSTs for batch completions, anomaly detections, and balance thresholds. At-least-once delivery with exponential retry.

Register an endpoint

  1. Open dashboard → Settings → Webhooks.
  2. Add your endpoint URL (must be HTTPS).
  3. Pick which event types to subscribe to (or all).
  4. Copy the generated webhook signing secret — stored once, shown once. Use it to verify signatures.
  5. Anoman fires a test event immediately so you can confirm your endpoint is reachable.

You can register up to 3 endpoints per customer. Multiple endpoints receive the same event in parallel — useful for separating production from a staging mirror.

What we fire today

Event typeWhen
batch.completedA queued batch job finished. Payload includes cost + savings.
batch.failedA batch job exhausted retries and won't recover.
batch.escalatedJob was promoted from batch to realtime to meet SLA. Higher cost.
anomaly.detectedZ-score or ML model flagged unusual behavior. Severity ∈ {low, medium, high}.
balance.thresholdPrepaid balance crossed a configured low-balance threshold.
key.budget_warningPer-key budget crossed 80% / 90% / 100%.
guardrail.spikeBurst of blocks exceeded baseline — possible attack or misconfigured agent.

Example bodies

Every webhook body is a single JSON object with these top-level keys: event_id, event_type, occurred_at, data, meta.

POST {your-url} — batch.completed

{
  "event_id": "evt_5kJh4nQp",
  "event_type": "batch.completed",
  "occurred_at": "2026-05-28T14:23:17.412Z",
  "data": {
    "batch_job_id": "job_abc123",
    "api_key_id": "key_xyz789",
    "model": "deepseek-v3",
    "status": "complete",
    "prompt_tokens": 4892,
    "completion_tokens": 1203,
    "cost_usd": "0.0028",
    "savings_usd": "0.0084",
    "poll_url": "/anoman/v1/batch/job_abc123",
    "completed_at": "2026-05-28T14:23:15.001Z"
  },
  "meta": {
    "delivery_attempt": 1
  }
}

POST {your-url} — anomaly.detected

{
  "event_id": "evt_8mPq2rTb",
  "event_type": "anomaly.detected",
  "occurred_at": "2026-05-28T11:04:32.190Z",
  "data": {
    "anomaly_id": "anm_def456",
    "severity": "high",
    "rule": "token_spike",
    "api_key_id": "key_xyz789",
    "agent_id": "support-bot-v2",
    "message": "Token burn rate 4.2× rolling baseline over last 5 minutes",
    "z_score": 5.8,
    "dashboard_url": "https://app.anoman.io/dashboard/anomalies/anm_def456"
  },
  "meta": {
    "delivery_attempt": 1
  }
}

POST {your-url} — balance.threshold

{
  "event_id": "evt_2vRn7sKw",
  "event_type": "balance.threshold",
  "occurred_at": "2026-05-28T08:15:00.000Z",
  "data": {
    "threshold": "low",
    "current_balance_usd": "4.12",
    "trigger_threshold_usd": "5.00",
    "billing_account_id": "ba_ghi789"
  },
  "meta": {
    "delivery_attempt": 1
  }
}

Always verify before processing

Every webhook request carries two headers:

  • x-anoman-timestamp — Unix timestamp the event was dispatched.
  • x-anoman-signature — hex-encoded HMAC-SHA256 of {timestamp}.{raw_body} using your signing secret.

Always: (1) check the timestamp is within 5 minutes (replay protection), (2) recompute the HMAC over the raw request body, (3) compare in constant time.

Python — verify signature (FastAPI)

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
 
app = FastAPI()
SECRET = os.environ["ANOMAN_WEBHOOK_SECRET"].encode()
 
@app.post("/webhooks/anoman")
async def receive_webhook(request: Request):
    raw_body = await request.body()
    sig_header = request.headers.get("x-anoman-signature", "")
    timestamp = request.headers.get("x-anoman-timestamp", "")
 
    # Reject events older than 5 minutes — limits replay window
    import time
    if abs(time.time() - int(timestamp)) > 300:
        raise HTTPException(400, "stale_timestamp")
 
    # Signature is HMAC-SHA256 over: timestamp + "." + raw_body
    signed_payload = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
 
    if not hmac.compare_digest(sig_header, expected):
        raise HTTPException(401, "invalid_signature")
 
    event = await request.json()
    # Process the event...
    return {"received": True}

TypeScript — verify (Express)

import express from "express";
import crypto from "crypto";
 
const app = express();
const SECRET = process.env.ANOMAN_WEBHOOK_SECRET!;
 
// Important: use raw body — JSON parsing would re-encode and break the signature
app.post(
  "/webhooks/anoman",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.header("x-anoman-signature") ?? "";
    const ts = req.header("x-anoman-timestamp") ?? "";
 
    // Stale timestamp protection
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
      return res.status(400).send("stale_timestamp");
    }
 
    const signedPayload = Buffer.concat([
      Buffer.from(`${ts}.`),
      req.body,  // raw Buffer
    ]);
    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(signedPayload)
      .digest("hex");
 
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send("invalid_signature");
    }
 
    const event = JSON.parse(req.body.toString());
    // Process the event...
    res.json({ received: true });
  },
);
 
app.listen(3000);

Node — minimal signature check

// Pure Node, no framework — useful in Cloudflare Workers / Lambda
const crypto = require("crypto");
 
function verifyAnomanSignature(rawBody, sigHeader, tsHeader, secret) {
  // 1. Stale timestamp (>5 min) → reject
  if (Math.abs(Date.now() / 1000 - Number(tsHeader)) > 300) return false;
 
  // 2. Re-compute HMAC
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${tsHeader}.${rawBody}`)
    .digest("hex");
 
  // 3. Constant-time compare
  return crypto.timingSafeEqual(
    Buffer.from(sigHeader),
    Buffer.from(expected),
  );
}

Don't parse before verifying. JSON parsing then re-serializing breaks the byte exactness the HMAC depends on. Verify against the raw body bytes; only parse after the signature checks out.

At-least-once

We treat any 2xx response as success. Anything else (including timeouts > 10s) triggers a retry on this schedule:

  • Attempt 1: immediate
  • Attempt 2: 30 seconds later
  • Attempt 3: 5 minutes later
  • Attempt 4: 1 hour later
  • Attempt 5: 6 hours later (final)

After attempt 5 the event is moved to the failed-deliveries queue (visible in dashboard) and a separate webhook.delivery_exhausted notification is emailed to your account admin.

Each delivery attempt increments meta.delivery_attempt in the payload. Use it to log + debug. Because we retry on any non-2xx, your endpoint must be idempotent:

Python — idempotency via event_id

import sqlite3
from datetime import datetime
 
# Persist event_ids you've processed. Any DB works — Redis SETNX,
# Postgres UNIQUE constraint, DynamoDB conditional put, etc.
db = sqlite3.connect("webhook_events.db")
db.execute("""
    CREATE TABLE IF NOT EXISTS processed_events (
        event_id TEXT PRIMARY KEY,
        received_at TEXT
    )
""")
db.commit()
 
def process_event(event):
    event_id = event["event_id"]
    # Atomic: insert-or-fail. If already processed, return 200 OK.
    try:
        db.execute(
            "INSERT INTO processed_events VALUES (?, ?)",
            (event_id, datetime.utcnow().isoformat()),
        )
        db.commit()
    except sqlite3.IntegrityError:
        return  # Already handled; idempotent no-op
 
    # Now do the real work — guaranteed once-per-event
    if event["event_type"] == "batch.completed":
        notify_user(event["data"]["batch_job_id"])
    elif event["event_type"] == "anomaly.detected":
        alert_oncall(event["data"]["anomaly_id"])

Replay + manual dispatch

  • The dashboard webhook page has a “Send test event” button that dispatches a synthetic payload of each event type.
  • The delivery log shows every attempt with status code + response body. Replay any individual attempt with one click.
  • For local development, use a tunnel like ngrok or cloudflared tunnel to expose localhost:3000 over HTTPS. The signing secret stays the same.

Set up your first webhook.

Takes 60 seconds. Free test events to verify wiring.

On this page