Webhooks

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

Last updated

Configure a webhook endpoint and Nemo Router will POST a JSON payload whenever an alert fires. Webhooks are one of five delivery channels for alerts (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

TriggerSeveritySource
Org balance drops below $10HIGHCredit reserve+settle pipeline
Budget hits a threshold (50% / 80% / 100%)WARNINGCRITICALBudget polling
RPM/TPM limit hit repeatedlyWARNINGRate-limit window observer
Guardrail blocks a requestINFOGuardrail hook
Cost-leak detector tripsCRITICALBackground scanner
Admin action (key created/rotated/deleted, member added)INFOAudit-trail writer

Channel bindings let you route each event type to a different webhook. Configure under Manage → Logging → Alert Channels in the dashboard.

Configuration

curl -X POST https://api.nemorouter.ai/nemo/channel/new \
  -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": "[Nemo Router] 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 [Nemo Router]
  • 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, Nemo Router 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/channel/test \
  -H "Authorization: Bearer sk-nemo-your-key" \
  -H "Content-Type: application/json" \
  -d '{
    "channel_type": "webhook",
    "config": {
      "url": "https://hooks.your-app.com/nemo",
      "secret": "whsec_a-long-random-string"
    }
  }'

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

Next Steps

Was this page helpful?