Webhooks
Meridian Blue posts a JSON event to your registered URLs whenever something operationally important happens — a key crosses a spend threshold, every provider in a chain failed, a request finished. Use webhooks for spend alerts, on-call paging, and downstream analytics.
Configure endpoints
Each tenant manages its own webhook delivery URLs through the dashboard or directly via the API. Per-tenant URLs receive only that tenant's events and are signed with that tenant's webhookSecret (see Signature verification).
# Read current configuration
curl https://api.meridianblue.ai/api/v1/webhooks \
-H "Authorization: Bearer $MB_DASHBOARD_JWT"
# Replace the URL list (idempotent)
curl -X PUT https://api.meridianblue.ai/api/v1/webhooks \
-H "Authorization: Bearer $MB_DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{"urls":["https://hooks.acme.example/meridian","https://siem.acme.example/ingest"]}'
Operators may also fan out events globally via the deployment-level WEBHOOK_URLS env var (comma-separated). Per-tenant URLs and the global env-var list receive every fired event with a 5 second timeout — failures are logged but never block the API caller.
Events
| Event | Fires when |
|---|---|
request.completed | A proxy request finished successfully (after billing has been recorded). |
spend.50_percent | A key first crosses 50% of its credit limit in the current billing cycle. |
spend.80_percent | A key first crosses 80% of its credit limit (drives the dashboard banner). |
budget.exceeded | A key first reaches 100% of its credit limit. Subsequent requests are blocked with 402. |
providers.exhausted | Every provider in a fallback chain failed; the caller saw a 502 all_providers_failed. |
fallback.triggered | A non-primary provider succeeded after the primary failed. |
incident.created | An EU AI Act Article 73 incident was persisted — either filed manually via the dashboard or auto-detected by the monitoring sampler. Drives on-call paging. |
sub_processor.changed | An operator added, edited, or removed an entry in the GDPR Art. 28 sub-processor registry. See Sub-processor change webhook. |
Each spend event fires once per billing cycle (idempotent via the key's notifiedThresholds field). The list is reset whenever the key's creditsConsumed rolls back to zero on its resetInterval.
Envelope shape
Every event ships in the same outer envelope. The data object varies per event.
{
"event": "spend.80_percent",
"timestamp": "2026-04-29T13:34:42.123Z",
"data": { /* event-specific fields */ }
}
The HTTP request also carries an X-Meridian-Event header containing the same event name, useful for routing without parsing the body, plus an X-Meridian-Signature header that authenticates the payload (see below).
Signature verification
Every webhook delivered to a per-tenant URL carries an X-Meridian-Signature header of the form sha256=<hex> — an HMAC-SHA256 of the exact raw request body, computed with that tenant's webhookSecret. Verify the signature before trusting any field on the payload.
import { createHmac, timingSafeEqual } from "crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.MERIDIAN_WEBHOOK_SECRET;
app.post(
"/webhooks/meridian",
// IMPORTANT: capture the raw body — JSON.parse changes whitespace and breaks HMAC
express.raw({ type: "application/json" }),
(req, res) => {
const received = req.headers["x-meridian-signature"] ?? "";
const [scheme, hex] = received.split("=");
if (scheme !== "sha256") return res.status(401).end();
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (
hex.length !== expected.length ||
!timingSafeEqual(Buffer.from(hex, "hex"), Buffer.from(expected, "hex"))
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event...
res.status(204).end();
},
);
The secret is generated automatically when the tenant is first created and is rotatable from the dashboard's webhook settings page. Operator-managed env-var fan-out URLs (the global WEBHOOK_URLS list) receive unsigned payloads — those are intended for internal SIEM ingestion behind your firewall.
Spend webhook payload
All three spend events (spend.50_percent, spend.80_percent, budget.exceeded) carry the same shape. The percentage field tells you which threshold fired.
{
"event": "spend.80_percent",
"timestamp": "2026-04-29T13:34:42.123Z",
"data": {
"keyId": "6531a9...",
"tenantId": "6531a8...",
"percentage": 80,
"keyName": "prod-api-key",
"consumed": 802.4,
"limit": 1000
}
}
Both keyId and tenantId are MongoDB ObjectId strings — use them to look up the key in your dashboard or correlate with usage exports.
Provider-attempt webhooks
fallback.triggered and providers.exhausted include the chain that was walked, so on-call dashboards can attribute failures to specific upstream providers.
{
"event": "providers.exhausted",
"timestamp": "2026-04-29T13:34:42.123Z",
"data": {
"requestId": "01J2...",
"modelChain": ["gpt-4o", "claude-sonnet-4-5-20241022"],
"providerAttempts": [
{ "provider": "openai", "status": "failed", "latencyMs": 312 },
{ "provider": "anthropic", "status": "failed", "latencyMs": 204 }
]
}
}
Sub-processor change webhook
Subscribe to sub_processor.changed to satisfy the GDPR Article 28 obligation that processors give controllers advance notice when sub-processors are added or removed. The event fires from the DB-backed sub-processor registry immediately on every owner-issued POST, PATCH, or DELETE.
{
"event": "sub_processor.changed",
"timestamp": "2026-04-29T13:34:42.123Z",
"data": {
"action": "created",
"name": "OpenAI",
"version": "2026.04.1",
"changed_at": "2026-04-29T13:34:42.121Z"
}
}
action is one of "created", "updated", or "deleted". Once your handler observes a change, re-pull GET /api/v1/sub-processors for the full current list — the webhook is intentionally minimal so you don't drift from the canonical view if multiple changes arrive out of order.
Delivery semantics
- At-most-once today. Webhooks are fire-and-forget — a 5xx or timeout from your endpoint is logged but not retried.
- Order is not guaranteed across events. Use
timestampto reconcile. - Spend events are idempotent at the source (notifiedThresholds), so duplicate deliveries cannot happen even on receiver retries.
- Method & content type — always
POSTwithContent-Type: application/json; user agentMeridianBlue-Webhook/1.0.
X-Meridian-Signature on every event.
Per-tenant payloads are HMAC-SHA256 signed with your tenant's webhookSecret — see Signature verification. Globally fanned-out env-var URLs (operator-only) carry no signature and must run behind a firewall.
Prometheus metrics
If you'd rather pull telemetry than receive pushes, scrape https://api.meridianblue.ai/metrics — a standard Prometheus exposition (text/plain, version 0.0.4) with counters and histograms for requests, errors, fallbacks, latency, tokens, and credits. See Observability.