Webhooks

Receive HTTPS notifications from NemoRouter for alerts, spend events, and audit actions

Last updated

Configure a webhook endpoint and NemoRouter will POST a JSON payload when a budget threshold is crossed. Webhook is one of five delivery channel types (email, Slack, Teams, Jira, webhook) — they all share the same payload shape, so anything you can do in Slack you can do over HTTP.

When webhooks fire

Today, budget threshold crossings are the event that reaches a webhook (or any other channel you've bound):

TriggerSeveritySource
A budget hits a soft threshold you configured (for example 50% / 80% / 100%)WARNINGCRITICALBudget threshold dispatch

You bind a channel to a budget's thresholds on the Budgets page — that's where each threshold's destinations are chosen. Create the channels themselves under Observability → Alerts → Channels. More alert categories (errors, latency, outages) are on the roadmap and show as "Coming soon" on the Alerts page — they don't fire yet.

Configuration

curl -X POST https://api.nemorouter.ai/nemo/channels \
  -H "Authorization: Bearer sk-nemo-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production alerts",
    "channel_type": "webhook",
    "config": {
      "url": "https://hooks.your-app.com/nemo",
      "secret": "whsec_a-long-random-string",
      "headers": {
        "X-Custom-Header": "optional"
      }
    },
    "is_active": true
  }'

The secret is optional but strongly recommended — without it, anyone who learns your webhook URL can replay payloads.

Payload shape

Every webhook gets the same JSON envelope, regardless of trigger:

{
  "title": "[NemoRouter] Budget depletion warning — balance below threshold",
  "message": "Org acme-inc is below $10 in credits.",
  "severity": "HIGH",
  "details": {
    "org_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "current_balance": 4.27,
    "threshold": 10,
    "action": "Top up credits to avoid service interruption"
  },
  "service": "nemo-backend"
}
  • title — short human-readable summary, always prefixed with [NemoRouter]
  • message — longer description suitable for chat
  • severityCRITICAL | HIGH | WARNING | INFO
  • details — event-specific context (org_id, request_id, amount, key_alias, etc.)
  • service — always nemo-backend

The details object varies by event type. The keys you'll most commonly see: org_id, current_balance, threshold, request_id, key_alias, guardrail_name, model.

Signature verification

When a secret is configured, NemoRouter signs the exact bytes it sends with HMAC-SHA256 and sets the header:

X-Nemo-Signature: sha256=<hex-digest>

The signed body is canonical JSON: json.dumps(payload, separators=(",", ":"), sort_keys=True). Use the same serialization when computing the verification hash.

Node.js verification

import crypto from "node:crypto";

export function verifyNemoSignature(rawBody: string, signatureHeader: string, secret: string): boolean {
  if (!signatureHeader?.startsWith("sha256=")) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  const provided = signatureHeader.slice(7);
  // constant-time compare to avoid timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(provided, "hex"),
  );
}

Python verification

import hmac
import hashlib

def verify_nemo_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    provided = signature_header[7:]
    return hmac.compare_digest(expected, provided)

Endpoint requirements

  • HTTPS only. HTTP URLs are rejected at create time.
  • Public DNS resolution. Loopback, private (RFC1918), link-local, and cloud-metadata addresses are blocked by an SSRF guard.
  • Respond within 10s with any 2xx status. Slower responses are recorded as failures.
  • Don't return secrets or keys in the response body. Nemo only inspects the status code.

Retries and delivery semantics

Webhooks are fire-and-forget with no automatic retry. If your endpoint returns a non-2xx (or times out), the failure is logged and the event is dropped. This is intentional — we don't want to retry a payment-failure alert at midnight.

If you need guaranteed delivery, pair a webhook with an Alert Channel of type email or slack so a human sees the same event through a separate path.

Testing

Send a test payload from the dashboard (Alert Channels → row → Send test) or via the API:

curl -X POST https://api.nemorouter.ai/nemo/channels/{channel_id}/test \
  -H "Authorization: Bearer sk-nemo-your-key"

Replace {channel_id} with the id of a channel you already created via POST /nemo/channels. The test fires against that channel's stored configuration — no body required.

Test payloads carry severity: "INFO" and details.test: true so you can filter them out in production.

Next Steps

  • Budget Controls — Cap spend per org, team, and key; route threshold alerts to your channels
  • Alert Channels (email, Slack, Teams, Jira, webhook) are created in the dashboard under Observability → Alerts → Channels
  • API Key Management — Rotate the key that signs management calls
  • Chat Completions API — The LLM request path that triggers most webhooks
Was this page helpful?