# Webhooks Si la mission a un `webhook_url` (HTTPS public obligatoire), nous envoyons un `POST` JSON signé à chaque étape de l'appel. C'est le moyen le plus rapide d'être prévenu d'une question : préfère-le au sondage pour n8n, Hermes ou ton serveur. ## Événements | Type | Quand | `data` | |---|---|---| | `appel.decroche` | L'interlocuteur a décroché | `{}` | | `appel.attente` | Mise en attente / reprise | `{"en_attente": true}` | | `appel.question` | L'agent a besoin d'une réponse (45 s, voir [Questions en direct](questions.md)) | `{"question_id": "q_…", "question": "…", "expires_at": "…", "seconds_left": 44, "delai_s": 45, "prolongation_possible": true}` | | `appel.question_prolongation` | Résultat d'une demande de prolongation de 60 s | `{"question_id": "q_…", "etat": "acceptee"\|"refusee"\|"impossible", "expires_at": "…", "seconds_left": 98}` | | `appel.rappel_suggere` | Fonctionnement actuel de la ligne : occupé, non décroché, ou l'interlocuteur souhaite être rappelé. Rien n'est relancé tout seul : relance d'un clic avec `POST /v1/missions/{id}/rappeler` | `{"motif": "occupe"\|"pas_de_reponse"\|"rappel_demande", "tentative": 1}` (+ `"suggere_le": "…"` si une heure a été donnée, + `"question_ouverte": "…"`) | | `appel.rappel` | Mode automatique seulement (non activé aujourd'hui) : relance planifiée | `{"motif": "occupe", "tentative": 2, "prevu_le": "…"}` (+ `"question_ouverte": {"question_id", "question"}`) | | `appel.transfert` | « Prendre l'appel » (mode pont) : on t'appelle, tu es en ligne, ou échec (pas de réponse sous 30 s : l'agent reprend) | `{"etat": "sonnerie"\|"connecte"\|"echec", "mode": "pont", "raison": "…"}` | | `appel.entrant` | L'interlocuteur rappelle notre numéro : la mission reprend (statut `en_cours`, nouvelle tentative) | `{"numero": "+33…", "tentative": 2}` | | `appel.fin` | Appel terminé | `{"resultat", "resume", "duree_facturee_s", "credits_debites", "attente_s", "tentatives", "etats"}` ; `etats` : parcours de l'appel (`sonnerie`, `menu`, `attente`, `reprise`, `conversation`, `repondeur`, `transfert`, `fin`) | Corps : ```json {"id": "evt_8c1d…", "type": "appel.fin", "mission_id": "mis_3f2a…", "data": {"resultat": "reussi", "resume": "Table réservée vendredi 20 h 30 pour 4, au nom de Moha K.", "duree_facturee_s": 94, "credits_debites": 157, "attente_s": 30, "tentatives": 1, "etats": ["sonnerie", "menu", "attente", "reprise", "conversation", "fin"]}} ``` ## En-têtes et signature | En-tête | Valeur | |---|---| | `X-Webhook-Id` | Identifiant de livraison (le même pour tous les essais d'un même envoi) | | `X-Webhook-Timestamp` | Horodatage Unix en secondes, recalculé à chaque essai | | `X-Webhook-Signature` | `sha256=` suivi du HMAC-SHA256 en hexadécimal minuscule, calculé avec ton secret de webhook sur `"{timestamp}.{corps brut}"` | L'algorithme exact : 1. Prends l'en-tête `X-Webhook-Timestamp` tel quel (une chaîne de chiffres). 2. Concatène : timestamp, un point `.`, puis le **corps brut** de la requête, octet pour octet. Ne relis pas le JSON pour le réécrire : le moindre espace changé casse la signature. 3. Calcule HMAC-SHA256 de ce message avec ton secret (`whsec_…`, en entier, préfixe compris) comme clé. 4. Compare `"sha256=" + hex` à `X-Webhook-Signature` avec une comparaison à temps constant. 5. Refuse aussi un horodatage à plus de 5 minutes de ton horloge (anti-rejeu). Ton secret de webhook (`whsec_…`) est propre à ton compte. Il n'est pas encore affiché dans l'espace client : demande-le au support en attendant. ### Python (FastAPI) ```python import hashlib, hmac, os, time from fastapi import FastAPI, HTTPException, Request SECRET = os.environ["DIALYNE_WEBHOOK_SECRET"].encode() # whsec_… app = FastAPI() def signature_valide(ts: str, corps: bytes, signature: str) -> bool: if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False attendu = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + corps, hashlib.sha256).hexdigest() return hmac.compare_digest(attendu, signature) @app.post("/hooks/telephone") async def hook(request: Request): corps = await request.body() # corps BRUT, avant tout décodage JSON if not signature_valide(request.headers.get("x-webhook-timestamp", ""), corps, request.headers.get("x-webhook-signature", "")): raise HTTPException(401) evt = await request.json() if evt["type"] == "appel.question": ... # répondre vite : POST /v1/missions/{mission_id}/answer (ou /question/prolonger d'abord) return {"ok": True} ``` ### Python (Flask) ```python import hashlib, hmac, os, time from flask import Flask, abort, request SECRET = os.environ["DIALYNE_WEBHOOK_SECRET"].encode() app = Flask(__name__) @app.post("/hooks/telephone") def hook(): ts = request.headers.get("X-Webhook-Timestamp", "") corps = request.get_data() # octets bruts attendu = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + corps, hashlib.sha256).hexdigest() if not ts.isdigit() or abs(time.time() - int(ts)) > 300 \ or not hmac.compare_digest(attendu, request.headers.get("X-Webhook-Signature", "")): abort(401) evt = request.get_json() return {"ok": True} ``` ### JavaScript (Node.js + Express) ```js import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.DIALYNE_WEBHOOK_SECRET; // whsec_… const app = express(); function signatureValide(ts, corps, signature) { if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const attendu = "sha256=" + crypto.createHmac("sha256", SECRET).update(`${ts}.`).update(corps).digest("hex"); // timingSafeEqual exige deux tampons de même longueur return typeof signature === "string" && signature.length === attendu.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(attendu)); } // express.raw : on garde le corps BRUT (Buffer) pour la signature, au lieu de express.json() app.post("/hooks/telephone", express.raw({ type: "application/json" }), (req, res) => { if (!signatureValide(req.get("X-Webhook-Timestamp") ?? "", req.body, req.get("X-Webhook-Signature"))) { return res.sendStatus(401); } const evt = JSON.parse(req.body.toString("utf8")); res.json({ ok: true }); // répondre tout de suite, traiter ensuite if (evt.type === "appel.question") { /* POST /v1/missions/{mission_id}/answer */ } }); app.listen(3000); ``` ### JavaScript (fonction Web standard : Next.js, Cloudflare Workers, Deno, Bun) ```js async function signatureValide(request, secret) { const ts = request.headers.get("x-webhook-timestamp") ?? ""; const signature = request.headers.get("x-webhook-signature") ?? ""; const corps = new Uint8Array(await request.clone().arrayBuffer()); if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const cle = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const message = new Uint8Array([...new TextEncoder().encode(`${ts}.`), ...corps]); const mac = new Uint8Array(await crypto.subtle.sign("HMAC", cle, message)); const attendu = "sha256=" + [...mac].map((b) => b.toString(16).padStart(2, "0")).join(""); if (attendu.length !== signature.length) return false; let diff = 0; for (let i = 0; i < attendu.length; i++) diff |= attendu.charCodeAt(i) ^ signature.charCodeAt(i); return diff === 0; } ``` ### Vérifier ton code Avec le secret `whsec_test`, l'horodatage `1700000000` et le corps `{"id":"evt_1","type":"appel.decroche","mission_id":"mis_1","data":{}}`, la signature attendue est : ``` sha256=6a56cd5741601863edf0a4db53ecc158f42f82516a7184b01c8e65104a315f0d ``` (Pense à désactiver la vérification de l'horodatage pour ce test, il date de 2023.) ## Réessais Réponds `2xx` rapidement (en moins de 10 s ; traite en arrière-plan). Sinon : 5 essais au total, à 0 s, 10 s, 1 min, 10 min et 1 h. Un `4xx` (hors 408 et 429) arrête les essais. Déduplique avec `id` ou `X-Webhook-Id` : le même événement peut arriver deux fois.