Aller au contenu

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).

POST sur l’URL configurée :

POST /votre-endpoint HTTP/1.1
content-type: application/json
elvn-timestamp: 1754121600
elvn-signature: v1=3f7a1c…9be2

Body (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).

  • Signature : elvn-signature: v1=<hex><hex> = HMAC-SHA256(secret, timestamp + "." + body) en hexadécimal minuscule ; timestamp = l’en-tête elvn-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 de localhost/*.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-timestamp plus vieux que ~5 min (anti-rejeu), comparer les signatures en temps constant.
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;
}
Fenêtre de terminal
SECRET='mon-secret-webhook'
TS=1754121600
BODY='{"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 :

Fenêtre de terminal
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.)

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.

Fenêtre de terminal
# secret commun (depuis le dossier du relais)
wrangler secret put ELVN_WEBHOOK_SECRET # le même secret que dans la règle Elvn

Chaque relais réutilise la fonction verifyElvnSignature ci-dessus (à coller au-dessus de l’export default).

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 });
},
};
  1. Créer la règle d’alerte avec l’URL du relais + le secret partagé.
  2. Cliquer « Envoyer un test » : le relais doit recevoir un payload "test": true et le message doit arriver dans Discord/Slack/ntfy.
  3. En cas de statut rejected:* dans le dashboard : l’URL viole les contraintes ci-dessus. En cas de failed:http-401 : secrets désynchronisés entre la règle et le relais.