Router — Webhooks
Webhooks sortants pour s'abonner à des événements Meridian en temps réel. Évite le polling pour :
- Alertes opérationnelles —
billing.low_balance,guard.halt - Dashboards compliance — tracer tous les halts Guard en temps réel
- Alimentation d'outils internes — Slack, PagerDuty, SOC, BI, etc.
Créer une subscription
curl https://router.signalorange.ca/api/v1/webhooks \
-H "Authorization: Bearer $ROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://votre-app.com/hooks/meridian",
"event_types": ["guard.halt", "billing.low_balance"],
"description": "SOC — alertes Guard + budget"
}'
Réponse 201 Created — le secret est retourné une seule fois :
{
"id": 7,
"url": "https://votre-app.com/hooks/meridian",
"event_types": ["guard.halt", "billing.low_balance"],
"description": "SOC — alertes Guard + budget",
"is_active": true,
"secret": "a1b2c3d4e5f6...",
"inserted_at": "2026-04-21T12:00:00Z"
}
Stockez le secret dans votre gestionnaire de secrets — il ne sera plus jamais affiché.
Événements disponibles
| Event | Quand | Déclencheur |
|---|---|---|
guard.halt |
Requête bloquée par Guard en mode strict | Immédiat (avant l'appel upstream) |
guard.detection |
Toute détection Guard (verbose, réservé) | Pas encore câblé |
billing.low_balance |
Balance sous le seuil configuré, pas d'auto-recharge | Cron horaire (LowBalanceNotifierWorker) |
billing.monthly_threshold |
Cumul mensuel franchit un seuil (réservé) | Pas encore câblé |
router.error_rate |
Taux d'erreurs dépasse un seuil (réservé) | Pas encore câblé |
Format d'un webhook
Chaque livraison est un POST avec 4 headers + un body JSON signé :
POST https://votre-app.com/hooks/meridian
Content-Type: application/json
User-Agent: Meridian-Webhook/1.0
X-Meridian-Event: guard.halt
X-Meridian-Delivery-Id: 1234
X-Meridian-Timestamp: 1745244896
X-Meridian-Signature: t=1745244896,sha256=a1b2c3...
{
"event": "guard.halt",
"organization_id": 42,
"policy_id": 1,
"request_id": "halt-uuid-abc",
"halted_at": "2026-04-21T12:34:56Z",
"detections_count": 2,
"detections": [
{"detector": "regex:nas", "category": "pii", "severity": "critical", "redacted": "[REDACTED:NAS]"}
]
}
Vérifier la signature (OBLIGATOIRE)
La signature X-Meridian-Signature est de la forme t=<unix_ts>,sha256=<hex> où
hex = HMAC-SHA256(secret, "<unix_ts>.<raw_body>"). Le timestamp est aussi exposé
dans X-Meridian-Timestamp — rejetez toute livraison dont le timestamp s'écarte
de plus de 5 minutes de l'horloge de votre receveur (protection anti-rejeu).
Node.js
import crypto from "crypto";
function verifySignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.trim().split("="))
);
const ts = parseInt(parts.t, 10);
if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;
const expected =
"t=" + ts + ",sha256=" +
crypto
.createHmac("sha256", Buffer.from(secret, "hex"))
.update(ts + "." + rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
app.post("/hooks/meridian", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("x-meridian-signature");
const secret = process.env.MERIDIAN_WEBHOOK_SECRET; // hex-encoded
if (!verifySignature(req.body, signature, secret)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
// Traiter l'événement...
res.status(200).send("ok");
});
Python
import hmac, hashlib, time
def verify_signature(raw_body: bytes, header: str, secret_hex: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
ts = int(parts.get("t", "0"))
if not ts or abs(int(time.time()) - ts) > 300:
return False
secret_bytes = bytes.fromhex(secret_hex)
signed = f"{ts}.".encode() + raw_body
expected = f"t={ts},sha256=" + hmac.new(secret_bytes, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(header, expected)
# Flask example
@app.route("/hooks/meridian", methods=["POST"])
def hook():
raw = request.get_data() # bytes, not decoded
sig = request.headers.get("X-Meridian-Signature", "")
if not verify_signature(raw, sig, os.environ["MERIDIAN_WEBHOOK_SECRET"]):
return "Invalid signature", 401
event = request.get_json()
# ...
return "ok", 200
Elixir
def verify(secret_hex, raw_body, header) do
with %{"t" => t_str, "sha256" => _} = parts <-
header |> String.split(",") |> Map.new(fn p ->
[k, v] = String.split(String.trim(p), "=", parts: 2); {k, v}
end),
{ts, ""} <- Integer.parse(t_str),
true <- abs(System.system_time(:second) - ts) <= 300 do
secret = Base.decode16!(secret_hex, case: :lower)
payload = "#{ts}.#{raw_body}"
hex = :crypto.mac(:hmac, :sha256, secret, payload) |> Base.encode16(case: :lower)
expected = "t=#{ts},sha256=#{hex}"
Plug.Crypto.secure_compare(header, expected)
else
_ -> false
end
end
Réponses attendues du receiver
- 2xx → delivery réussie, pas de retry
- 4xx → erreur client, pas de retry (votre endpoint sera ignoré pour cet événement)
- 5xx / timeout / erreur réseau → retry avec exponential backoff, jusqu'à 6 tentatives au total
Timeout côté Meridian : 10 secondes par tentative. Votre receiver doit répondre rapidement — si vous avez du travail lourd à faire, enqueue-le et répondez 200 tout de suite.
Inspecter les livraisons
curl https://router.signalorange.ca/api/v1/webhooks/7/deliveries?limit=20 \
-H "Authorization: Bearer $ROUTER_API_KEY"
{
"data": [
{
"id": 1234,
"event_type": "guard.halt",
"status": "success",
"attempt_count": 1,
"response_code": 200,
"delivered_at": "2026-04-21T12:34:57Z",
"inserted_at": "2026-04-21T12:34:56Z"
},
{
"id": 1235,
"event_type": "billing.low_balance",
"status": "retrying",
"attempt_count": 3,
"response_code": 502,
"delivered_at": null,
"inserted_at": "2026-04-21T11:00:00Z"
}
]
}
Rétention : 30 jours. Un sweeper (WebhookDeliveriesSweeperWorker) supprime les entrées plus vieilles chaque jour à 04:00 UTC.
Statuts possibles :
pending— créé, pas encore tentésuccess— 2xx reçufailed— 4xx reçu, abandonné sans retryretrying— 5xx / timeout, Oban va retenter avec backoff
CRUD complet
# Lister vos webhooks
GET /api/v1/webhooks
# Modifier (url / event_types / is_active / description)
PATCH /api/v1/webhooks/:id
{ "is_active": false }
# Supprimer
DELETE /api/v1/webhooks/:id
Bonnes pratiques
- Verify signature en PREMIER, avant de parser ou de logger quoi que ce soit
- Répondez rapidement (< 1 seconde idéalement, le timeout côté Router est 10s)
- Mettez le traitement asynchrone si vous devez écrire dans une DB, envoyer un Slack, etc.
- Loggez
X-Meridian-Delivery-Id— s'il y a un retry vous aurez la trace de la tentative précédente - Idempotence côté receiver : si Meridian retry après un 5xx, vous recevrez le même event plusieurs fois. Utilisez
delivery_idcomme clé d'idempotence - Rotation : supprimez et recréez un webhook pour régénérer le secret
Et après ?
- Router — Guard — cause principale de
guard.halt - Router — Facturation — configure
low_balance_alert_centspour déclencherbilling.low_balance - Router — API d'audit — corrélation entre
request_iddu webhook et l'entrée audit