# Webhooks

> Index de la documentation : https://wahasend.com/docs/fr/llms.txt · Tout en un fichier : https://wahasend.com/docs/fr.md · URL de base de l'API : https://app.wahasend.com/api/v1

WahaSend envoie des événements à une URL à vous : réponses des clients, accusés de remise, numéro déconnecté, assistant qui a besoin d'une personne, nouvelle commande. **Developer** reçoit tous les événements (fonction `webhooks`). Les propriétaires d'**Assistant** et de **Shop** peuvent aussi définir un webhook et reçoivent les événements de leur produit : `bot.needs_review` et `bot.handoff`, plus `commerce.order` avec Shop. Réglez l'adresse dans le tableau de bord (**Pour les développeurs** → URL de webhook) ou par l'API :

<a id="webhook-set"></a>

#### Définir ou effacer l'URL du webhook

`PUT /api/v1/me/webhook`

Une URL par organisation. `null` arrête les événements. La première URL crée aussi le secret de signature, renvoyé une fois ici dans `signingSecret` (relisez-le avec `GET /me/webhook`).

- Débloqué par: Assistant, Shop, Developer (`webhooks | bots`)
- Clé du compte seulement

**Corps de la requête**

| Champ | Type | Obligatoire | Description |
| --- | --- | --- | --- |
| `webhookUrl` | string (URL) \| null | oui | Adresse http(s) publique, 2000 caractères max. Les adresses privées ou locales sont refusées. |

**Exemple**

curl:

```bash
curl -X PUT https://app.wahasend.com/api/v1/me/webhook \
  -H "X-API-Key: $WAHASEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9"
}'
```

JavaScript:

```js
const res = await fetch('https://app.wahasend.com/api/v1/me/webhook', {
  method: 'PUT',
  headers: { 'X-API-Key': process.env.WAHASEND_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9"
  }),
});
const data = await res.json();
```

Python:

```python
import os
import requests

r = requests.put(
    "https://app.wahasend.com/api/v1/me/webhook",
    headers={"X-API-Key": os.environ["WAHASEND_API_KEY"]},
    json={
        "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9",
    },
)
print(r.json())
```

**Réponse 200**

```json
{
  "success": true,
  "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9",
  "signingSecret": "whsec_5f0c…64 hex characters…"
}
```

**Erreurs possibles**

- [`VALIDATION_ERROR`](https://wahasend.com/docs/fr/errors.md#validation_error) (400): Validation failed. Check the details for specific field errors.
- [`PRODUCT_REQUIRED`](https://wahasend.com/docs/fr/errors.md#product_required) (403): This feature is not included in your products.

<a id="webhook-get"></a>

#### URL du webhook et secret de signature

`GET /api/v1/me/webhook`

L'URL et le secret pour [vérifier les signatures](https://wahasend.com/docs/fr/webhooks.md#webhook-security). Propriétaires et admins seulement.

- Débloqué par: Assistant, Shop, Developer (`webhooks | bots`)
- Clé du compte seulement

> `signingSecret` vaut null tant qu'aucune URL n'a été définie (ou sur un serveur sans chiffrement configuré : les envois ne sont alors pas signés).

**Exemple**

curl:

```bash
curl https://app.wahasend.com/api/v1/me/webhook \
  -H "X-API-Key: $WAHASEND_API_KEY"
```

JavaScript:

```js
const res = await fetch('https://app.wahasend.com/api/v1/me/webhook', {
  headers: { 'X-API-Key': process.env.WAHASEND_API_KEY },
});
const data = await res.json();
```

Python:

```python
import os
import requests

r = requests.get(
    "https://app.wahasend.com/api/v1/me/webhook",
    headers={"X-API-Key": os.environ["WAHASEND_API_KEY"]},
)
print(r.json())
```

**Réponse 200**

```json
{
  "success": true,
  "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9",
  "signingSecret": "whsec_5f0c…64 hex characters…"
}
```

**Erreurs possibles**

- [`PRODUCT_REQUIRED`](https://wahasend.com/docs/fr/errors.md#product_required) (403): This feature is not included in your products.
- [`ROLE_FORBIDDEN`](https://wahasend.com/docs/fr/errors.md#role_forbidden) (403): Your role in this organization does not allow this. Ask an owner or admin.

<a id="webhook-secret-regenerate"></a>

#### Remplacer le secret de signature

`POST /api/v1/me/webhook/regenerate-secret`

Un nouveau secret ; les envois sont signés avec lui aussitôt et l'ancien ne vérifie plus. Mettez votre récepteur à jour juste après.

- Débloqué par: Assistant, Shop, Developer (`webhooks | bots`)
- Clé du compte seulement

**Exemple**

curl:

```bash
curl -X POST https://app.wahasend.com/api/v1/me/webhook/regenerate-secret \
  -H "X-API-Key: $WAHASEND_API_KEY"
```

JavaScript:

```js
const res = await fetch('https://app.wahasend.com/api/v1/me/webhook/regenerate-secret', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.WAHASEND_API_KEY },
});
const data = await res.json();
```

Python:

```python
import os
import requests

r = requests.post(
    "https://app.wahasend.com/api/v1/me/webhook/regenerate-secret",
    headers={"X-API-Key": os.environ["WAHASEND_API_KEY"]},
)
print(r.json())
```

**Réponse 200**

```json
{
  "success": true,
  "signingSecret": "whsec_91ab…64 hex characters…",
  "warning": "Update your receiver with this secret now. Deliveries are signed with it from now on."
}
```

**Erreurs possibles**

- [`PRODUCT_REQUIRED`](https://wahasend.com/docs/fr/errors.md#product_required) (403): This feature is not included in your products.
- [`WEBHOOK_SIGNING_UNAVAILABLE`](https://wahasend.com/docs/fr/errors.md#webhook_signing_unavailable) (503): Webhook signing is not available on this server yet (SECRETS_ENCRYPTION_KEY is not set).

<a id="webhook-delivery"></a>

### Comment les événements arrivent

- `POST` avec `Content-Type: application/json` et `User-Agent: WahaSend/1.0`, corps `{ event, data, timestamp }`.
- En-têtes : `X-WahaSend-Event` (le nom de l'événement), `X-WahaSend-Delivery` (un identifiant par événement, le même à chaque nouvelle tentative : il permet d'écarter les doublons) et `X-WahaSend-Signature` ([à vérifier](https://wahasend.com/docs/fr/webhooks.md#webhook-security)).
- Répondez un 2xx en moins de 10 secondes. Sinon (ou délai dépassé, ou redirection : elles ne sont pas suivies) l'envoi est retenté : 5 tentatives en tout, espacées d'environ 5 s, 10 s, 20 s et 40 s. Ensuite l'événement est abandonné.
- Les événements partent vers l'URL réglée au moment de l'envoi ; une adresse privée ou locale est refusée là aussi.
- L'ordre n'est pas garanti et un événement peut arriver deux fois : rendez votre traitement idempotent (avec `messageId` / `wahaMessageId`).

Chaque événement:

```json
{
  "event": "message.delivered",
  "data": { … },
  "timestamp": "2026-09-30T10:30:15.204Z"
}
```

<a id="webhook-security"></a>

### Vérifier que c'est nous

Chaque événement est signé avec le **secret de signature** de votre organisation (`whsec_…`, créé avec votre première URL de webhook ; visible dans le tableau de bord ou avec `GET /me/webhook`). L'en-tête ressemble à ceci :

```http
X-WahaSend-Signature: t=1790762475,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

1. Lisez `t` (secondes Unix) et `v1` dans l'en-tête.
2. Calculez le HMAC-SHA256 avec votre secret sur `t + "." + body`, body étant le corps **brut** de la requête tel que reçu (pas du JSON re-sérialisé). Encodez-le en hexadécimal.
3. Comparez-le à `v1` en temps constant. Refusez l'événement s'ils diffèrent.
4. Refusez-le aussi si `t` est à plus de 5 minutes de votre horloge : un ancien événement rejoué par quelqu'un d'autre échoue alors.

Node.js (Express):

```js
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.WAHASEND_WEBHOOK_SECRET; // whsec_…

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header || '').split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as bytes: sign-check those, then parse
app.post('/hooks/wahasend', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.get('X-WahaSend-Signature'))) return res.status(400).end();
  const { event, data } = JSON.parse(raw);
  // … handle it (dedupe on req.get('X-WahaSend-Delivery'))
  res.sendStatus(200);
});
```

Python (Flask):

```python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WAHASEND_WEBHOOK_SECRET"].encode()  # whsec_…

def verify(raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_seconds:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/hooks/wahasend")
def wahasend():
    raw = request.get_data()  # the raw bytes, before any JSON parsing
    if not verify(raw, request.headers.get("X-WahaSend-Signature", "")):
        abort(400)
    event = request.get_json()
    # … handle it (dedupe on request.headers["X-WahaSend-Delivery"])
    return "", 200
```

> Remplacez le secret avec **Nouvelle clé** dans le tableau de bord (ou `POST /me/webhook/regenerate-secret`) : les événements sont signés avec le nouveau aussitôt, mettez donc votre récepteur à jour juste après. Gardez une URL en HTTPS.

<a id="webhook-events"></a>

### Événements

| Événement | Quand | Qui le reçoit |
| --- | --- | --- |
| `message.inbound` | Quelqu'un a écrit à votre numéro. | Developer |
| `message.delivered` · `message.read` · `message.failed` | Un message parti par envoi texte, envoi de média, lot, campagne, OTP, rappel ou assistant a changé de statut. (`message.sent` est rare : les envois sont déjà marqués envoyés.) | Developer |
| `poll.vote` | Quelqu'un a voté à un sondage envoyé. | Developer |
| `session.needs_scan` · `session.disconnected` · `session.recovered` | Un numéro doit être relié à nouveau, est tombé, ou est revenu. | Developer |
| `bot.needs_review` · `bot.handoff` | Un assistant n'a pas pu répondre, ou a passé la main à une personne. | Assistant, Shop ou Developer |
| `commerce.order` | Un client a passé une commande (paiement à la livraison) via l'assistant boutique. | Shop ou Developer |

message.inbound:

```json
{
  "event": "message.inbound",
  "data": {
    "sender": "22212345678",
    "senderName": "Aïcha",
    "body": "Is the blue dress still available?",
    "messageType": "chat",
    "wahaMessageId": "false_22212345678@c.us_3A1F9C2B7E",
    "containerId": "6d2b9c1e-8f4a-4e37-a5c2-1b9e7d3f0a64",
    "numberPhone": "22200000000",
    "timestamp": "2026-09-30T10:41:07.512Z"
  },
  "timestamp": "2026-09-30T10:41:07.790Z"
}
```

- `sender` est le numéro en chiffres, ou l'identifiant `…@lid` quand WhatsApp cache le numéro.
- Champs facultatifs : `isGroup` + `groupId` (messages de groupe), `latitude` + `longitude` (une position, `messageType: "location"`), `selectedButtonId`, `selectedRowId`.
- Les messages sans texte (photo sans légende, sticker) ne donnent pas d'événement. Une note vocale n'arrive que sur les comptes avec un assistant, sous forme de transcription (`messageType: "voice"`).
- `containerId` et `numberPhone` disent lequel de vos numéros l'a reçu (`numberPhone` vaut null pour un numéro pas encore relié).

message.delivered (aussi .read, .failed):

```json
{
  "event": "message.delivered",
  "data": {
    "messageId": "4f0c6a1e-2b7d-4c1a-9e35-7d8f2a6b1c90",
    "recipient": "22212345678",
    "type": "alert",
    "status": "delivered",
    "wahaMessageId": "true_22212345678@c.us_3EB0C767D26A1D2E5A8F",
    "containerId": "6d2b9c1e-8f4a-4e37-a5c2-1b9e7d3f0a64",
    "numberPhone": "22200000000",
    "timestamp": "2026-09-30T10:30:15.101Z"
  },
  "timestamp": "2026-09-30T10:30:15.204Z"
}
```

`type` vaut `alert` (envois texte et médias, lots, rappels), `bulk` (campagnes, avec `campaignId` en plus), `otp` ou `bot_reply`. `containerId` / `numberPhone` désignent le numéro d'envoi (null pour les messages d'avant leur enregistrement).

session.disconnected:

```json
{
  "event": "session.disconnected",
  "data": {
    "status": "FAILED",
    "message": "Your WhatsApp number has disconnected and automatic recovery failed. Please reconnect it from your dashboard.",
    "retriesExhausted": true,
    "lastAttempt": "2026-09-30T03:12:40.000Z",
    "containerId": "6d2b9c1e-8f4a-4e37-a5c2-1b9e7d3f0a64",
    "numberPhone": "22200000000",
    "session": "n6d2b9c1e8f4a4e37",
    "timestamp": "2026-09-30T03:12:40.020Z"
  },
  "timestamp": "2026-09-30T03:12:40.300Z"
}
```

Quand le serveur WhatsApp qui héberge le numéro est en panne, `session.disconnected` porte `"reason": "instance_down"` au lieu de `retriesExhausted` ; le numéro revient tout seul. `session.needs_scan` porte `"status": "SCAN_QR_CODE"` ; `session.recovered` porte `"status": "WORKING"` et `recoveredAt`. Les trois ont `containerId`, `numberPhone` et `session`.

bot.needs_review / bot.handoff:

```json
{
  "event": "bot.needs_review",
  "data": {
    "botId": "8b2e…",
    "conversationId": "f41c…",
    "contactPhone": "22212345678",
    "question": "Do you deliver to Rosso?",
    "lang": "fr",
    "timestamp": "2026-09-30T11:02:10.000Z"
  },
  "timestamp": "2026-09-30T11:02:10.180Z"
}
```

`reason` s'ajoute quand ce n'est pas un simple « pas de réponse » : `"away"` (hors horaires ; sans `lang` alors) ou `"ai_quota"` (l'enveloppe d'IA du mois est épuisée).

commerce.order:

```json
{
  "event": "commerce.order",
  "data": {
    "botId": "8b2e…",
    "contactPhone": "22212345678",
    "orderNumber": "A-1042",
    "total": 12500,
    "currency": "MRU",
    "timestamp": "2026-09-30T12:20:44.000Z"
  },
  "timestamp": "2026-09-30T12:20:44.260Z"
}
```

`poll.vote` transmet le vote de WhatsApp tel quel (`vote`, `poll`) avec votre `containerId` et `numberPhone` en plus.
