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