Webhooks d'alerte
Un webhook = une URL https + un secret, configurés par règle d’alerte dans
/:site/alerts (bouton « Envoyer un test » sur chaque règle).
La requête reçue
Section intitulée « La requête reçue »POST sur l’URL configurée :
POST /votre-endpoint HTTP/1.1content-type: application/jsonelvn-timestamp: 1754121600elvn-signature: v1=3f7a1c…9be2Body (déclenchement réel) :
{ "type": "spike", "site": "blog.exemple.fr", "rule": 12, "value": 87, "threshold": 50, "at": "2026-08-02T09:20:00.000Z"}| Champ | Type | Contenu |
|---|---|---|
type |
string | Type de règle : spike | drop | silence | goal |
site |
string | Domaine du site concerné |
rule |
number | Id de la règle (visible dans le dashboard) |
value |
number | Valeur déclenchante (visiteurs 5 min pour spike, visiteurs 12 h pour drop, heures de silence, conversions du jour) |
threshold |
number | null | Seuil configuré (null quand sans objet) |
at |
string | Instant du déclenchement, ISO 8601 UTC |
Les envois du bouton « Envoyer un test » portent en plus "test": true
(mêmes en-têtes, même signature).
Contrat de livraison
Section intitulée « Contrat de livraison »- Signature :
elvn-signature: v1=<hex>où<hex> = HMAC-SHA256(secret, timestamp + "." + body)en hexadécimal minuscule ;timestamp= l’en-têteelvn-timestamp(secondes Unix) ;body= corps JSON octet pour octet. - Timeout 10 s ; les redirections ne sont pas suivies (un 3xx compte comme un échec) ; seul un statut 2xx vaut succès.
- Retries : 3 tentatives supplémentaires à ~1 min, 5 min puis 25 min. Rendez votre récepteur idempotent (un timeout côté Elvn après votre 200 peut produire un doublon).
- Échecs journalisés dans l’historique de la règle, sans bloquer le canal email.
- Contraintes d’URL (rejetées à la configuration comme à l’envoi) :
https://uniquement, pas d’identifiants dans l’URL, pas delocalhost/*.localhost/*.internal, pas d’IP privée/littérale (protection SSRF). - Recommandations côté récepteur : vérifier la signature avant de parser
le JSON, refuser un
elvn-timestampplus vieux que ~5 min (anti-rejeu), comparer les signatures en temps constant.
Vérifier la signature
Section intitulée « Vérifier la signature »JavaScript (Worker / Node ≥ 20)
Section intitulée « JavaScript (Worker / Node ≥ 20) »async function verifyElvnSignature(secret, request, body) { const timestamp = request.headers.get("elvn-timestamp") ?? ""; const header = request.headers.get("elvn-signature") ?? ""; if (!header.startsWith("v1=")) return false; // anti-rejeu : 5 minutes de tolérance if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`), ); const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join(""); const given = header.slice(3);
// comparaison en temps constant if (expected.length !== given.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i); return diff === 0;}curl / openssl (reproduire une signature)
Section intitulée « curl / openssl (reproduire une signature) »SECRET='mon-secret-webhook'TS=1754121600BODY='{"type":"spike","site":"blog.exemple.fr","rule":12,"value":87,"threshold":50,"at":"2026-08-02T09:20:00.000Z"}'
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //'# -> doit être identique au header elvn-signature (après "v1=")Et pour simuler une livraison Elvn vers votre récepteur :
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')curl -i -X POST https://votre-endpoint.exemple.fr/elvn \ -H "content-type: application/json" \ -H "elvn-timestamp: $TS" \ -H "elvn-signature: v1=$SIG" \ --data "$BODY"(Pensez à un TS frais si votre récepteur applique la tolérance anti-rejeu.)
Relais prêts à coller
Section intitulée « Relais prêts à coller »Elvn n’envoie que le POST JSON signé : les payloads Discord/Slack/ntfy se construisent côté récepteur. Les trois relais ci-dessous sont des Workers Cloudflare autonomes (gratuits sur le plan Free) : créer un Worker, coller le code, poser les secrets, puis donner son URL comme webhook de la règle.
# secret commun (depuis le dossier du relais)wrangler secret put ELVN_WEBHOOK_SECRET # le même secret que dans la règle ElvnChaque relais réutilise la fonction verifyElvnSignature ci-dessus (à coller
au-dessus de l’export default).
Discord (embed)
Section intitulée « Discord (embed) »Secret supplémentaire : wrangler secret put DISCORD_WEBHOOK_URL (URL
« Webhook » du salon Discord).
// relay-discord — colle verifyElvnSignature ici
const COLORS = { spike: 0x2ecc71, drop: 0xe67e22, silence: 0xe74c3c, goal: 0x3498db };const TITLES = { spike: "Pic de trafic", drop: "Trafic en baisse", silence: "Aucun événement reçu", goal: "Objectif atteint",};
export default { async fetch(request, env) { if (request.method !== "POST") return new Response(null, { status: 405 }); const body = await request.text(); if (!(await verifyElvnSignature(env.ELVN_WEBHOOK_SECRET, request, body))) { return new Response(null, { status: 401 }); } const a = JSON.parse(body);
const embed = { title: `${a.test ? "[TEST] " : ""}${TITLES[a.type] ?? a.type} — ${a.site}`, color: COLORS[a.type] ?? 0x95a5a6, fields: [ { name: "Valeur", value: String(a.value), inline: true }, { name: "Seuil", value: a.threshold === null ? "—" : String(a.threshold), inline: true }, { name: "Règle", value: `#${a.rule}`, inline: true }, ], timestamp: a.at, footer: { text: "Elvn.live" }, };
const resp = await fetch(env.DISCORD_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ embeds: [embed] }), }); return new Response(null, { status: resp.ok ? 200 : 502 }); },};Secret supplémentaire : wrangler secret put SLACK_WEBHOOK_URL (URL
« Incoming Webhook » de l’app Slack).
// relay-slack — colle verifyElvnSignature ici
const EMOJI = { spike: ":chart_with_upwards_trend:", drop: ":chart_with_downwards_trend:", silence: ":no_bell:", goal: ":dart:",};
export default { async fetch(request, env) { if (request.method !== "POST") return new Response(null, { status: 405 }); const body = await request.text(); if (!(await verifyElvnSignature(env.ELVN_WEBHOOK_SECRET, request, body))) { return new Response(null, { status: 401 }); } const a = JSON.parse(body);
const text = `${a.test ? "[TEST] " : ""}${EMOJI[a.type] ?? ""} *${a.type}* sur *${a.site}* — ` + `valeur ${a.value}${a.threshold !== null ? ` (seuil ${a.threshold})` : ""} · règle #${a.rule} · ${a.at}`;
const resp = await fetch(env.SLACK_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, blocks: [{ type: "section", text: { type: "mrkdwn", text } }], }), }); return new Response(null, { status: resp.ok ? 200 : 502 }); },};Variable supplémentaire : NTFY_URL (ex. https://ntfy.sh/mon-topic-secret,
ou votre instance auto-hébergée). Un simple vars suffit si le topic n’est
pas sensible, sinon wrangler secret put NTFY_URL.
// relay-ntfy — colle verifyElvnSignature ici
const PRIORITY = { spike: "high", drop: "high", silence: "urgent", goal: "default" };const TAGS = { spike: "chart_with_upwards_trend", drop: "chart_with_downwards_trend", silence: "rotating_light", goal: "dart",};
export default { async fetch(request, env) { if (request.method !== "POST") return new Response(null, { status: 405 }); const body = await request.text(); if (!(await verifyElvnSignature(env.ELVN_WEBHOOK_SECRET, request, body))) { return new Response(null, { status: 401 }); } const a = JSON.parse(body);
const resp = await fetch(env.NTFY_URL, { method: "POST", headers: { Title: `${a.test ? "[TEST] " : ""}Elvn ${a.type} — ${a.site}`, Priority: PRIORITY[a.type] ?? "default", Tags: TAGS[a.type] ?? "bell", }, body: `valeur ${a.value}${a.threshold !== null ? ` (seuil ${a.threshold})` : ""} · règle #${a.rule} · ${a.at}`, }); return new Response(null, { status: resp.ok ? 200 : 502 }); },};Mise au point
Section intitulée « Mise au point »- Créer la règle d’alerte avec l’URL du relais + le secret partagé.
- Cliquer « Envoyer un test » : le relais doit recevoir un payload
"test": trueet le message doit arriver dans Discord/Slack/ntfy. - En cas de statut
rejected:*dans le dashboard : l’URL viole les contraintes ci-dessus. En cas defailed:http-401: secrets désynchronisés entre la règle et le relais.