Webhooks
Recevez les événements par requêtes HTTPS signées : vérification de l’URL, signature, nouvelles tentatives et révocation.
Fonctionnement
Avec le transport webhook, Woupi envoie chaque événement par une requête HTTP POST à une URL de rappel que vous contrôlez. C’est le bon choix pour un serveur qui fonctionne en continu.
- Vous créez l’abonnement avec un jeton d’application (voir EventSub), en fournissant l’URL de rappel et un secret.
- Woupi envoie une requête de vérification contenant un défi; vous le renvoyez tel quel.
- L’abonnement passe au statut
enabledet les notifications commencent à arriver. - Chaque message est signé avec votre secret; vous vérifiez la signature avant de le traiter.
- Si l’abonnement prend fin (échecs répétés, autorisation retirée), Woupi vous envoie un message de révocation.
Exigences pour l’URL de rappel
- HTTPS public sur le port 443 (aucun autre port n’est accepté), sans identifiants dans l’URL.
- Le nom d’hôte doit être un nom de domaine, pas une adresse IP, et ne doit pas pointer vers une adresse privée, locale ou réservée (par exemple 10.0.0.0/8, 127.0.0.1, 192.168.0.0/16, ::1). La vérification a lieu à la création et avant chaque tentative; une destination refusée compte comme un échec, avec l’erreur
blocked_destination. - Les redirections ne sont pas suivies : une réponse 3xx compte comme un échec.
- Répondez par un code 2xx en moins de 10 secondes. Accusez réception d’abord, puis traitez l’événement en arrière-plan.
- Le secret compte de 10 à 100 caractères. Générez-le au hasard, gardez-le côté serveur et utilisez-en un différent par abonnement si possible.
Remarque
Pour tester en local, exposez votre serveur avec un tunnel HTTPS public; les adresses localhost sont refusées.
En-têtes
Chaque requête envoyée par Woupi contient ces en-têtes :
| En-tête | Description |
|---|---|
Woupi-Message-Id | Identifiant unique du message (UUID). Il reste le même d’une nouvelle tentative à l’autre. |
Woupi-Message-Timestamp | Moment de l’envoi, en ISO 8601 UTC. Il change à chaque tentative. |
Woupi-Message-Signature | sha256= suivi de la signature HMAC-SHA256 en hexadécimal. |
Woupi-Message-Type | notification | webhook_callback_verification | revocation |
Woupi-Message-Retry | Nombre de tentatives précédentes pour ce message (0 au premier envoi). |
Woupi-Subscription-Type | Type d’événement de l’abonnement, par exemple channel.follow. |
Woupi-Subscription-Version | Version de l’abonnement (1). |
User-Agent | Woupi-EventSub/1 |
Content-Type | application/json |
Exemple de notification :
POST /woupi/webhook HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Woupi-EventSub/1
Woupi-Message-Id: 7d0c6f0e-2a4b-4f7e-8c1d-5b9a3e6f2d10
Woupi-Message-Timestamp: 2026-09-24T14:07:31.482Z
Woupi-Message-Signature: sha256=4f8a1c2e9b7d3f6a0e5c8b1d2f4a7e9c3b6d0f1a8e2c5b7d9f3a6c0e4b8d2f1a
Woupi-Message-Type: notification
Woupi-Message-Retry: 0
Woupi-Subscription-Type: channel.follow
Woupi-Subscription-Version: 1
{"subscription":{"id":"f1c2ae15-3b4d-4c9e-9a7b-0d8e6f5a4c31","type":"channel.follow","version":"1","status":"enabled","condition":{"broadcaster_user_id":"1042"},"transport":{"method":"webhook"},"created_at":"2026-09-24T14:05:12.000Z"},"event":{"broadcaster_user_id":"1042","broadcaster_user_login":"lunaplays","broadcaster_user_name":"LunaPlays","user_id":"3318","user_login":"max_quebec","user_name":"Max","followed_at":"2026-09-24T14:07:31.000Z"}}| Type de message | Corps | Réponse attendue |
|---|---|---|
webhook_callback_verification | { subscription, challenge } | 2xx avec le défi brut comme corps |
notification | { subscription, event } | 2xx en moins de 10 secondes |
revocation | { subscription } | 2xx |
Vérifier la signature
La signature est un HMAC-SHA256 calculé avec votre secret sur la concaténation, sans séparateur, de l’identifiant du message, de l’horodatage (la valeur exacte de l’en-tête) et du corps brut de la requête. Elle est encodée en hexadécimal et préfixée par sha256=.
signature = "sha256=" + hex( HMAC-SHA256( secret, message_id + timestamp + raw_body ) )- Calculez la signature sur les octets reçus, avant toute analyse JSON : une nouvelle sérialisation changerait le résultat.
- Comparez les signatures en temps constant (
crypto.timingSafeEqual,hmac.compare_digest). - Rejetez tout message dont la signature ne correspond pas, y compris les requêtes de vérification.
Node.js avec Express :
// npm install express
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.WOUPI_WEBHOOK_SECRET; // the secret sent when creating the subscription
const MAX_AGE_MS = 10 * 60 * 1000;
const seen = new Map(); // message id -> time seen (use Redis or a database table in production)
const app = express();
// Keep the raw bytes: the signature covers the exact body that was sent.
app.post('/woupi/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('Woupi-Message-Id') || '';
const timestamp = req.get('Woupi-Message-Timestamp') || '';
const signature = req.get('Woupi-Message-Signature') || '';
const body = req.body; // Buffer
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(id + timestamp)
.update(body)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const age = Date.now() - Date.parse(timestamp);
if (!Number.isFinite(age) || Math.abs(age) > MAX_AGE_MS) {
return res.sendStatus(403);
}
const message = JSON.parse(body.toString('utf8'));
switch (req.get('Woupi-Message-Type')) {
case 'webhook_callback_verification':
return res.type('text/plain').send(message.challenge);
case 'notification':
if (seen.has(id)) return res.sendStatus(204); // already handled
seen.set(id, Date.now());
res.sendStatus(204); // acknowledge first, then do the work
handleEvent(message.subscription.type, message.event);
return;
case 'revocation':
console.warn('subscription revoked', message.subscription.id, message.subscription.status);
return res.sendStatus(204);
default:
return res.sendStatus(400);
}
});
function handleEvent(type, event) {
if (event.is_test) console.log('(test event)');
console.log(type, event);
}
// Forget message ids after 24 hours.
setInterval(() => {
const limit = Date.now() - 24 * 60 * 60 * 1000;
for (const [id, at] of seen) if (at < limit) seen.delete(id);
}, 60 * 60 * 1000);
app.listen(3000);Python avec Flask :
# pip install flask
import hashlib
import hmac
import os
import time
from datetime import datetime
from flask import Flask, request
SECRET = os.environ["WOUPI_WEBHOOK_SECRET"].encode() # the secret sent when creating the subscription
MAX_AGE_S = 10 * 60
seen = {} # message id -> time seen (use Redis or a database table in production)
app = Flask(__name__)
@app.post("/woupi/webhook")
def woupi_webhook():
msg_id = request.headers.get("Woupi-Message-Id", "")
timestamp = request.headers.get("Woupi-Message-Timestamp", "")
signature = request.headers.get("Woupi-Message-Signature", "")
body = request.get_data() # raw bytes, before any JSON parsing
digest = hmac.new(SECRET, (msg_id + timestamp).encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest("sha256=" + digest, signature):
return "", 403
try:
sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
except ValueError:
return "", 403
if abs(time.time() - sent_at) > MAX_AGE_S:
return "", 403
message = request.get_json()
kind = request.headers.get("Woupi-Message-Type")
if kind == "webhook_callback_verification":
return message["challenge"], 200, {"Content-Type": "text/plain"}
if kind == "notification":
if msg_id not in seen: # skip messages already handled
seen[msg_id] = time.time()
handle_event(message["subscription"]["type"], message["event"])
return "", 204
if kind == "revocation":
sub = message["subscription"]
print("subscription revoked", sub["id"], sub["status"])
return "", 204
return "", 400
def handle_event(event_type, event):
print(event_type, event)Répondre au défi de vérification
Dès la création d’un abonnement webhook, Woupi envoie un message de type webhook_callback_verification. Son corps contient l’abonnement et une chaîne challenge :
{
"subscription": {
"id": "f1c2ae15-3b4d-4c9e-9a7b-0d8e6f5a4c31",
"type": "channel.follow",
"version": "1",
"status": "webhook_callback_verification_pending",
"condition": { "broadcaster_user_id": "1042" },
"transport": { "method": "webhook" },
"created_at": "2026-09-24T14:05:12.000Z"
},
"challenge": "hQ3v9K1xZ7mP2cR8tY5wL0nB4sD6fG1j"
}Après avoir vérifié la signature, répondez par un code 2xx dont le corps est exactement la valeur de challenge, en texte brut (text/plain), sans guillemets ni JSON :
HTTP/1.1 200 OK
Content-Type: text/plain
hQ3v9K1xZ7mP2cR8tY5wL0nB4sD6fG1jAttention
Toute autre réponse (code d’erreur, corps JSON, délai dépassé) fait passer l’abonnement au statut webhook_callback_verification_failed. La vérification n’est pas retentée : supprimez l’abonnement et recréez-le après correction.
Nouvelles tentatives
Une notification est livrée quand votre serveur répond par un code 2xx en moins de 10 secondes. Tout le reste est un échec : code 3xx, 4xx ou 5xx, délai dépassé, erreur réseau ou destination refusée. Après un échec, Woupi retente l’envoi du même message (même Woupi-Message-Id) selon ce calendrier :
| Tentative | Délai après l’échec précédent | Woupi-Message-Retry |
|---|---|---|
| Envoi initial | immédiat | 0 |
| 1re reprise | 1 min | 1 |
| 2e reprise | 5 min | 2 |
| 3e reprise | 30 min | 3 |
| 4e reprise | 2 h | 4 |
| 5e reprise | 6 h | 5 |
Si la cinquième reprise échoue, le message est marqué comme échoué et n’est plus envoyé. Vous pouvez le renvoyer depuis le portail avec « Rejouer ».
Seuil d’échecs et révocation
Chaque tentative échouée augmente le compteur d’échecs consécutifs de l’abonnement; une livraison réussie le remet à zéro. À 50 échecs consécutifs, l’abonnement passe au statut notification_failures_exceeded, ne reçoit plus de notifications et Woupi envoie un message revocation (une seule tentative).
{
"subscription": {
"id": "f1c2ae15-3b4d-4c9e-9a7b-0d8e6f5a4c31",
"type": "channel.follow",
"version": "1",
"status": "notification_failures_exceeded",
"condition": { "broadcaster_user_id": "1042" },
"transport": { "method": "webhook" },
"created_at": "2026-09-24T14:05:12.000Z"
}
}Le champ subscription.status du message de révocation indique la raison :
| Statut | Raison | Que faire |
|---|---|---|
notification_failures_exceeded | 50 tentatives consécutives ont échoué. | Corrigez votre serveur, puis recréez l’abonnement. |
authorization_revoked | L’utilisateur dont dépendait l’abonnement a déconnecté votre application; l’abonnement est supprimé. | Demandez une nouvelle autorisation à l’utilisateur avant de vous réabonner. |
Répondez aux messages de révocation par un code 2xx après avoir vérifié la signature.
Protection contre la rejouabilité
- Dédoublonnez par
Woupi-Message-Id: une nouvelle tentative réutilise le même identifiant, par exemple si votre réponse s’est perdue alors que l’événement avait déjà été traité. Conservez les identifiants vus pendant au moins 24 heures, car les reprises s’étalent sur plus de 8 heures. - Rejetez les messages dont
Woupi-Message-Timestampdate de plus de 10 minutes. L’horodatage est renouvelé à chaque tentative et couvert par la signature : un message intercepté ne peut pas être renvoyé plus tard. - Gardez l’horloge de votre serveur synchronisée (NTP).
Remarque
« Rejouer » dans le portail crée un nouveau message avec un nouvel identifiant : il ne sera donc pas écarté par votre dédoublonnage.
Outils du portail
Dans le portail, ouvrez Applications, choisissez votre application, puis Événements. Vous y trouvez :
- Le tableau des abonnements : type, condition, transport, statut, nombre d’échecs, date de création et suppression.
- « Nouvel abonnement webhook » : un formulaire pour choisir le type, remplir la condition, l’URL de rappel et le secret (le bouton « Générer » en crée un au hasard). Aucun jeton n’est requis dans ce formulaire.
- Le journal des livraisons : les 100 derniers messages de chaque abonnement, avec l’heure, le type, le numéro de tentative, le code HTTP reçu, l’erreur et le début de votre réponse.
- « Rejouer » : renvoie une livraison sous forme de nouveau message, avec un nouveau
Woupi-Message-Id. - « Envoyer un événement test » : envoie à l’abonnement un exemple d’événement de son type, avec la chaîne de la condition et
"is_test": truedans l’objetevent. Ignorez ces événements dans votre logique de production.
