Router

Router — Webhooks

Abonnez vos services à des événements Router/Guard/Billing. HMAC-SHA256 signé, retry exponentiel, rétention 30 jours sur les livraisons. Évite le polling pour alertes low_balance, guard.halt, etc.

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çu
  • failed — 4xx reçu, abandonné sans retry
  • retrying — 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_id comme clé d'idempotence
  • Rotation : supprimez et recréez un webhook pour régénérer le secret

Et après ?