Envoyez du WhatsApp
sans toucher à Meta.

La Gateway est un service HTTP qui parle à l'API WhatsApp Cloud à votre place. Vous envoyez du JSON, vous recevez du JSON.

Vous n'avez ni token Meta à protéger, ni signature Graph à valider, ni structure de webhook imbriquée à démêler. La Gateway s'occupe des statuts de livraison, de la déduplication des événements et des nouvelles tentatives.

Votre application garde toute sa logique métier. La Gateway transporte des messages, elle ne décide de rien.

Le numéro expéditeur ne s'envoie pas

Aucune requête ne contient de numéro d'expéditeur. La Gateway le déduit de l'application authentifiée. Vous ne pouvez donc jamais émettre depuis le numéro de quelqu'un d'autre, même par erreur.

Ce que l'administrateur vous remet

URL de base X-App-ID X-API-Key callback_secret

La clé API n'est affichée qu'une fois. En cas de perte, l'administrateur en génère une nouvelle et l'ancienne cesse aussitôt de fonctionner.

Les deux sens
VOTRE APP GATEWAY WHATSAPP POST /messages/text VOTRE APP GATEWAY WHATSAPP POST votre callback_url
Vérifier que le service répond
# Seul endpoint public
curl https://gateway.exemple.sn/api/v1/health
{
  "status": "ok",
  "service": "whatsapp-gateway",
  "time": "2026-08-20T12:19:38+00:00"
}

Authentification

Chaque requête porte deux en-têtes. Il n'y a ni session, ni token à rafraîchir : les identifiants voyagent avec chaque appel.

En-têteValeur
X-App-IDL'UUID de votre application
X-API-KeyVotre clé secrète, préfixée wag_
Acceptapplication/json

Sans Accept: application/json, les erreurs de validation reviennent en HTML au lieu de JSON. C'est la première chose à vérifier si vos réponses d'erreur sont illisibles.

Réponses possibles

CodeCe que ça veut dire
401En-têtes absents ou identifiants invalides
403Identifiants bons, application désactivée
429Quota dépassé, 60 requêtes par minute

Un 401 ne précise jamais si c'est l'application ou la clé qui pose problème. Rien ne doit filtrer sur les applications existantes.

Gardez la clé dans une variable d'environnement. Jamais dans le code, jamais dans un dépôt Git.

Tester vos identifiants
curl https://gateway.exemple.sn/api/v1/me \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Accept: application/json"
use Illuminate\Support\Facades\Http;

$reponse = Http::withHeaders([
    'X-App-ID'  => config('gateway.app_id'),
    'X-API-Key' => config('gateway.api_key'),
])
    ->acceptJson()
    ->timeout(10)
    ->get(config('gateway.url') . '/api/v1/me');

dd($reponse->json());
import 'dart:convert';
import 'package:http/http.dart' as http;

final reponse = await http.get(
  Uri.parse('$baseUrl/api/v1/me'),
  headers: {
    'X-App-ID': appId,
    'X-API-Key': apiKey,
    'Accept': 'application/json',
  },
);

print(jsonDecode(reponse.body));
const reponse = await fetch(
  `${baseUrl}/api/v1/me`,
  {
    headers: {
      'X-App-ID': process.env.GATEWAY_APP_ID,
      'X-API-Key': process.env.GATEWAY_API_KEY,
      'Accept': 'application/json',
    },
  }
);

console.log(await reponse.json());
import os, requests

reponse = requests.get(
    f"{BASE_URL}/api/v1/me",
    headers={
        "X-App-ID": os.environ["GATEWAY_APP_ID"],
        "X-API-Key": os.environ["GATEWAY_API_KEY"],
        "Accept": "application/json",
    },
    timeout=10,
)

print(reponse.json())
Réponse
{
  "application": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Ma Boutique",
    "callback_url": "https://maboutique.sn/whatsapp"
  },
  "whatsapp_number": {
    "phone_number": "+221771111111",
    "display_name": "Ma Boutique",
    "is_active": true
  }
}

Message texte

POST /api/v1/messages/text Authentifié

Envoie un message libre. Réservé aux conversations déjà ouvertes.

ChampDescription
torequisNuméro au format E.164 : un + suivi de 8 à 15 chiffres
textrequisDe 1 à 4096 caractères

La fenêtre de 24 heures. WhatsApp n'autorise le texte libre que dans les 24 heures suivant le dernier message de la personne. Passé ce délai, l'envoi échoue avec le code Meta 131047 et vous devez passer par un template.

La réponse arrive en 202 : le message est accepté, pas encore livré. Conservez l'id pour suivre son acheminement.

Normalisez les numéros avant l'envoi. Un numéro sénégalais saisi sans indicatif — 771234567 — doit devenir +221771234567.

Requête
curl -X POST https://gateway.exemple.sn/api/v1/messages/text \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "to": "+221771234567",
    "text": "Votre commande CMD-4521 est prête."
  }'
class GatewayClient
{
    public function envoyerTexte(string $vers, string $texte): array
    {
        $reponse = Http::withHeaders([
            'X-App-ID'  => config('gateway.app_id'),
            'X-API-Key' => config('gateway.api_key'),
        ])
            ->acceptJson()
            ->timeout(10)
            ->post(config('gateway.url') . '/api/v1/messages/text', [
                'to'   => $vers,
                'text' => $texte,
            ]);

        return $reponse->json();
    }
}
class GatewayClient {
  final String baseUrl, appId, apiKey;

  GatewayClient(this.baseUrl, this.appId, this.apiKey);

  Map<String, String> get _entetes => {
    'X-App-ID': appId,
    'X-API-Key': apiKey,
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  };

  Future<Map<String, dynamic>> envoyerTexte(
    String vers, String texte,
  ) async {
    final reponse = await http.post(
      Uri.parse('$baseUrl/api/v1/messages/text'),
      headers: _entetes,
      body: jsonEncode({'to': vers, 'text': texte}),
    ).timeout(const Duration(seconds: 10));

    return jsonDecode(reponse.body);
  }
}
const entetes = {
  'X-App-ID': process.env.GATEWAY_APP_ID,
  'X-API-Key': process.env.GATEWAY_API_KEY,
  'Content-Type': 'application/json',
  'Accept': 'application/json',
};

async function envoyerTexte(vers, texte) {
  const reponse = await fetch(
    `${baseUrl}/api/v1/messages/text`,
    {
      method: 'POST',
      headers: entetes,
      body: JSON.stringify({ to: vers, text: texte }),
    }
  );

  return reponse.json();
}
ENTETES = {
    "X-App-ID": os.environ["GATEWAY_APP_ID"],
    "X-API-Key": os.environ["GATEWAY_API_KEY"],
    "Accept": "application/json",
}

def envoyer_texte(vers: str, texte: str) -> dict:
    reponse = requests.post(
        f"{BASE_URL}/api/v1/messages/text",
        headers=ENTETES,
        json={"to": vers, "text": texte},
        timeout=10,
    )
    return reponse.json()
Réponse 202
{
  "id": "3489c425-ce2a-480a-aa08-b485976c63e6",
  "status": "pending",
  "direction": "outbound",
  "type": "text",
  "to": "+221771234567",
  "from": "+221771111111",
  "created_at": "2026-08-20T12:19:38+00:00",
  "sent_at": null,
  "error": null
}

Template

POST /api/v1/messages/template Authentifié

Le seul moyen d'écrire à quelqu'un qui ne vous a pas contacté. Le modèle doit avoir été créé et approuvé par Meta au préalable — demandez-le à l'administrateur de la Gateway.

ChampDescription
torequisNuméro au format E.164
templaterequisNom exact du modèle approuvé
languageCode de langue : fr, fr_FR, en_US
parametersValeurs des variables, dans l'ordre

Les paramètres sont positionnels

Le premier élément remplit 1, le deuxième 2, et ainsi de suite. Pour un modèle qui dit « Bonjour 1, votre commande 2 est prête », vous envoyez ["Amadou", "CMD-4521"].

Le code de langue doit correspondre exactement à celui du modèle chez Meta. Un modèle en fr appelé avec fr_FR échoue avec l'erreur 132001.

Requête
curl -X POST https://gateway.exemple.sn/api/v1/messages/template \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "to": "+221771234567",
    "template": "confirmation_commande",
    "language": "fr",
    "parameters": ["Amadou", "CMD-4521", "15 000 FCFA"]
  }'
public function envoyerTemplate(
    string $vers,
    string $modele,
    array $parametres = [],
    string $langue = 'fr'
): array {
    return Http::withHeaders($this->entetes())
        ->acceptJson()
        ->post($this->url . '/api/v1/messages/template', [
            'to'         => $vers,
            'template'   => $modele,
            'language'   => $langue,
            'parameters' => $parametres,
        ])
        ->json();
}
Future<Map<String, dynamic>> envoyerTemplate({
  required String vers,
  required String modele,
  List<String> parametres = const [],
  String langue = 'fr',
}) async {
  final reponse = await http.post(
    Uri.parse('$baseUrl/api/v1/messages/template'),
    headers: _entetes,
    body: jsonEncode({
      'to': vers,
      'template': modele,
      'language': langue,
      'parameters': parametres,
    }),
  );

  return jsonDecode(reponse.body);
}
async function envoyerTemplate(
  vers, modele, parametres = [], langue = 'fr'
) {
  const reponse = await fetch(
    `${baseUrl}/api/v1/messages/template`,
    {
      method: 'POST',
      headers: entetes,
      body: JSON.stringify({
        to: vers,
        template: modele,
        language: langue,
        parameters: parametres,
      }),
    }
  );

  return reponse.json();
}
def envoyer_template(
    vers: str,
    modele: str,
    parametres: list[str] | None = None,
    langue: str = "fr",
) -> dict:
    reponse = requests.post(
        f"{BASE_URL}/api/v1/messages/template",
        headers=ENTETES,
        json={
            "to": vers,
            "template": modele,
            "language": langue,
            "parameters": parametres or [],
        },
        timeout=10,
    )
    return reponse.json()

Boutons et listes

POST /api/v1/messages/interactive Authentifié

Proposez des choix à toucher plutôt qu'à écrire. Vous récupérez ensuite l'identifiant exact de l'option choisie, sans avoir à interpréter du texte libre.

ChampDescription
torequisNuméro au format E.164
bodyrequisLe message, jusqu'à 1024 caractères
optionsrequisDe 1 à 10 choix, identifiants uniques
options[].idrequisVous revient tel quel au clic
options[].titlerequisTronqué à 20 caractères en boutons, 24 en liste
options[].descriptionListes uniquement, tronqué à 72 caractères
headerTitre au-dessus, 60 caractères
footerMention en dessous, 60 caractères
button_labelLibellé qui ouvre la liste

Le format se choisit tout seul

De 1 à 3 options, WhatsApp affiche des boutons sous le message. À partir de 4, une liste déroulante. Vous envoyez vos options, la Gateway retient le meilleur format disponible.

Les identifiants que vous définissez sont ceux qui vous reviennent dans le callback. Choisissez-les stables et parlants pour votre logique — catalogue plutôt que opt_3.

Requête
curl -X POST https://gateway.exemple.sn/api/v1/messages/interactive \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "to": "+221771234567",
    "header": "Ma Boutique",
    "body": "Que souhaitez-vous faire ?",
    "footer": "Touchez une option",
    "options": [
      { "id": "prix", "title": "Consulter les prix" },
      { "id": "commande", "title": "Passer commande" },
      { "id": "suivi", "title": "Suivre ma commande" }
    ]
  }'
public function envoyerOptions(
    string $vers,
    string $message,
    array $options
): array {
    return Http::withHeaders($this->entetes())
        ->acceptJson()
        ->post($this->url . '/api/v1/messages/interactive', [
            'to'      => $vers,
            'body'    => $message,
            'options' => $options,
        ])
        ->json();
}

// Appel
$client->envoyerOptions('+221771234567', 'Que faire ?', [
    ['id' => 'prix',     'title' => 'Consulter les prix'],
    ['id' => 'commande', 'title' => 'Passer commande'],
]);
class OptionGateway {
  final String id;
  final String titre;
  final String? description;

  const OptionGateway({
    required this.id,
    required this.titre,
    this.description,
  });

  Map<String, dynamic> versJson() => {
    'id': id,
    'title': titre,
    if (description != null) 'description': description,
  };
}

Future<Map<String, dynamic>> envoyerOptions({
  required String vers,
  required String message,
  required List<OptionGateway> options,
  String? entete,
}) async {
  final reponse = await http.post(
    Uri.parse('$baseUrl/api/v1/messages/interactive'),
    headers: _entetes,
    body: jsonEncode({
      'to': vers,
      'body': message,
      if (entete != null) 'header': entete,
      'options': options.map((o) => o.versJson()).toList(),
    }),
  );

  return jsonDecode(reponse.body);
}
async function envoyerOptions(vers, message, options, entete) {
  const reponse = await fetch(
    `${baseUrl}/api/v1/messages/interactive`,
    {
      method: 'POST',
      headers: entetes,
      body: JSON.stringify({
        to: vers,
        body: message,
        ...(entete && { header: entete }),
        options,
      }),
    }
  );

  return reponse.json();
}

// Appel
await envoyerOptions('+221771234567', 'Que faire ?', [
  { id: 'prix', title: 'Consulter les prix' },
  { id: 'commande', title: 'Passer commande' },
]);
def envoyer_options(
    vers: str,
    message: str,
    options: list[dict],
    entete: str | None = None,
) -> dict:
    charge = {"to": vers, "body": message, "options": options}

    if entete:
        charge["header"] = entete

    reponse = requests.post(
        f"{BASE_URL}/api/v1/messages/interactive",
        headers=ENTETES,
        json=charge,
        timeout=10,
    )
    return reponse.json()
Liste de 4 options ou plus
{
  "to": "+221771234567",
  "body": "Choisissez un produit",
  "button_label": "Voir la liste",
  "options": [
    { "id": "riz",      "title": "Riz",      "description": "Sac de 50 kg" },
    { "id": "mil",      "title": "Mil",      "description": "Sac de 50 kg" },
    { "id": "mais",     "title": "Maïs",     "description": "Sac de 50 kg" },
    { "id": "arachide", "title": "Arachide", "description": "Sac de 40 kg" }
  ]
}

Suivre un message

GET /api/v1/messages/{id} Authentifié

Retourne l'état courant d'un message que vous avez envoyé. L'id est celui reçu à l'envoi.

Vous ne pouvez consulter que vos propres messages. Deviner l'identifiant d'une autre application donne un 404.

C'est le seul moyen de savoir si un message a été livré ou lu. Enregistrez l'id de chaque envoi dans votre base au moment où vous le recevez.

Requête
curl https://gateway.exemple.sn/api/v1/messages/3489c425-ce2a-480a-aa08-b485976c63e6 \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Accept: application/json"
public function suivre(string $id): array
{
    return Http::withHeaders($this->entetes())
        ->acceptJson()
        ->get($this->url . "/api/v1/messages/{$id}")
        ->json();
}
Future<Map<String, dynamic>> suivre(String id) async {
  final reponse = await http.get(
    Uri.parse('$baseUrl/api/v1/messages/$id'),
    headers: _entetes,
  );

  return jsonDecode(reponse.body);
}
async function suivre(id) {
  const reponse = await fetch(
    `${baseUrl}/api/v1/messages/${id}`,
    { headers: entetes }
  );

  return reponse.json();
}
def suivre(identifiant: str) -> dict:
    reponse = requests.get(
        f"{BASE_URL}/api/v1/messages/{identifiant}",
        headers=ENTETES,
        timeout=10,
    )
    return reponse.json()
Réponse 200
{
  "id": "3489c425-ce2a-480a-aa08-b485976c63e6",
  "status": "read",
  "direction": "outbound",
  "type": "text",
  "to": "+221771234567",
  "from": "+221771111111",
  "created_at": "2026-08-20T12:19:38+00:00",
  "sent_at": "2026-08-20T12:19:41+00:00",
  "error": null
}

Envoyer un code de vérification

POST /api/v1/otp/send Authentifié

La Gateway génère le code, l'envoie par WhatsApp, gère son expiration et compte les tentatives. Votre application ne voit jamais le code.

ChampDescription
torequisNuméro au format E.164

Règles appliquées

RègleValeur
Validité5 minutes
Tentatives5 essais maximum
Délai entre demandes60 secondes
Codes actifsUn seul par numéro

Demander un nouveau code invalide le précédent. Un code vérifié est consommé et ne peut pas resservir.

Le code n'apparaît jamais dans la réponse HTTP. Il ne circule que jusqu'au téléphone du destinataire. Toute intégration qui aurait besoin de le lire côté serveur est un défaut de conception.

Requête
curl -X POST https://gateway.exemple.sn/api/v1/otp/send \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{ "to": "+221771234567" }'
public function envoyerCode(string $vers): array
{
    $reponse = Http::withHeaders($this->entetes())
        ->acceptJson()
        ->post($this->url . '/api/v1/otp/send', [
            'to' => $vers,
        ]);

    if ($reponse->status() === 429) {
        throw ValidationException::withMessages([
            'phone' => 'Patientez une minute avant de redemander un code.',
        ]);
    }

    return $reponse->json();
}
Future<Map<String, dynamic>> envoyerCode(String vers) async {
  final reponse = await http.post(
    Uri.parse('$baseUrl/api/v1/otp/send'),
    headers: _entetes,
    body: jsonEncode({'to': vers}),
  );

  if (reponse.statusCode == 429) {
    throw Exception(
      'Patientez une minute avant de redemander un code.',
    );
  }

  return jsonDecode(reponse.body);
}
async function envoyerCode(vers) {
  const reponse = await fetch(
    `${baseUrl}/api/v1/otp/send`,
    {
      method: 'POST',
      headers: entetes,
      body: JSON.stringify({ to: vers }),
    }
  );

  if (reponse.status === 429) {
    throw new Error(
      'Patientez une minute avant de redemander un code.'
    );
  }

  return reponse.json();
}
def envoyer_code(vers: str) -> dict:
    reponse = requests.post(
        f"{BASE_URL}/api/v1/otp/send",
        headers=ENTETES,
        json={"to": vers},
        timeout=10,
    )

    if reponse.status_code == 429:
        raise ValueError(
            "Patientez une minute avant de redemander un code."
        )

    return reponse.json()
Réponse 202
{
  "status": "sent",
  "message_id": "0a6cecf9-9ebf-4297-9cd9-3a71034a7fb7",
  "expires_at": "2026-08-19T17:10:26+00:00"
}

Vérifier un code

POST /api/v1/otp/verify Authentifié
ChampDescription
torequisLe même numéro que lors de l'envoi
coderequisDe 4 à 8 chiffres

La Gateway ne décide de rien

Elle répond à une seule question : cette personne contrôle-t-elle ce téléphone ? Ouvrir une session, créer un compte, valider une commande — tout cela reste votre décision.

Erreurs de vérification

errorCause
otp_not_foundAucun code en attente, ou déjà utilisé
otp_expiredLes 5 minutes sont passées
otp_invalidCode incorrect
otp_too_many_attempts5 essais atteints, redemander un code

Toutes reviennent en 422 avec verified: false.

Requête
curl -X POST https://gateway.exemple.sn/api/v1/otp/verify \
  -H "X-App-ID: $GATEWAY_APP_ID" \
  -H "X-API-Key: $GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{ "to": "+221771234567", "code": "482915" }'
public function verifierCode(string $vers, string $code): bool
{
    $reponse = Http::withHeaders($this->entetes())
        ->acceptJson()
        ->post($this->url . '/api/v1/otp/verify', [
            'to'   => $vers,
            'code' => $code,
        ]);

    return $reponse->successful()
        && $reponse->json('verified') === true;
}
Future<bool> verifierCode(String vers, String code) async {
  final reponse = await http.post(
    Uri.parse('$baseUrl/api/v1/otp/verify'),
    headers: _entetes,
    body: jsonEncode({'to': vers, 'code': code}),
  );

  if (reponse.statusCode != 200) return false;

  final corps = jsonDecode(reponse.body);
  return corps['verified'] == true;
}
async function verifierCode(vers, code) {
  const reponse = await fetch(
    `${baseUrl}/api/v1/otp/verify`,
    {
      method: 'POST',
      headers: entetes,
      body: JSON.stringify({ to: vers, code }),
    }
  );

  if (!reponse.ok) return false;

  const corps = await reponse.json();
  return corps.verified === true;
}
def verifier_code(vers: str, code: str) -> bool:
    reponse = requests.post(
        f"{BASE_URL}/api/v1/otp/verify",
        headers=ENTETES,
        json={"to": vers, "code": code},
        timeout=10,
    )

    if reponse.status_code != 200:
        return False

    return reponse.json().get("verified") is True
Réponse 200
{
  "verified": true,
  "phone": "+221771234567",
  "verified_at": "2026-08-19T17:06:46+00:00"
}
Réponse 422
{
  "verified": false,
  "error": "otp_invalid",
  "message": "Code incorrect."
}

Recevoir les messages

Dès qu'une personne vous écrit ou touche une option, la Gateway envoie une requête POST à l'URL que vous avez déclarée. C'est le seul moyen de recevoir : il n'y a pas d'endpoint à interroger en boucle.

Sans URL de callback, votre application peut parler mais pas entendre. Elle est indispensable dès que vous voulez un chatbot, un menu, ou toute réaction à ce que dit la personne.

En-têtes reçus

En-têteContenu
X-Gateway-Eventmessage.received
X-Gateway-TimestampHorodatage Unix de l'envoi
X-Gateway-Signaturesha256= suivi du HMAC

Champs du message

ChampDescription
idIdentifiant Gateway
wamidIdentifiant WhatsApp, votre clé de déduplication
typetext, interactive, button, image…
fromL'expéditeur, en E.164
textLe texte saisi, ou le libellé de l'option touchée
selectionPrésent uniquement sur un clic
rawLe payload WhatsApp brut, pour les cas non couverts

selection.id est l'identifiant exact que vous aviez défini à l'envoi. Aucune analyse de texte à faire : une personne qui touche « Voir le catalogue » vous renvoie catalogue.

Ce que vous recevez — texte
{
  "event": "message.received",
  "sent_at": "2026-08-20T13:17:45+00:00",
  "message": {
    "id": "ff7ccbe9-80f4-4e17-9315-7dbdb4a29b89",
    "wamid": "wamid.HBgMMjIx...",
    "type": "text",
    "from": "+221785830419",
    "to": "+221771111111",
    "text": "Je veux 5 sacs de riz",
    "timestamp": "2026-08-20T13:16:20+00:00",
    "raw": { }
  }
}
Ce que vous recevez — clic sur une option
{
  "event": "message.received",
  "sent_at": "2026-08-20T13:17:45+00:00",
  "message": {
    "id": "ff7ccbe9-80f4-4e17-9315-7dbdb4a29b89",
    "wamid": "wamid.HBgMMjIx...",
    "type": "interactive",
    "from": "+221785830419",
    "to": "+221771111111",
    "text": "Voir le catalogue",
    "selection": {
      "id": "catalogue",
      "title": "Voir le catalogue",
      "description": null
    },
    "timestamp": "2026-08-20T13:16:20+00:00",
    "raw": { }
  }
}
Lire l'entrée, quel que soit le type
// La sélection prime sur le texte
const entree =
  message.selection?.id ?? message.text;

Vérifier la signature

Votre URL de callback est publique. Sans vérification, n'importe qui peut vous envoyer de faux messages en se faisant passer pour vos utilisateurs. La signature prouve que la requête vient bien de la Gateway et que son contenu n'a pas été modifié en route.

La formule

signature = "sha256=" + HMAC_SHA256(
    timestamp + "." + corps_brut,
    callback_secret
)

Signez le corps brut, pas un JSON reconstruit. Si vous décodez puis ré-encodez le corps, l'ordre des clés ou l'échappement changent et la signature ne correspondra jamais. C'est de loin l'erreur d'intégration la plus fréquente.

Trois règles

Refusez toute requête dont le timestamp dépasse 5 minutes : cela empêche qu'une requête interceptée soit rejouée plus tard.

Comparez en temps constant — hash_equals, timingSafeEqual, compare_digest. Une comparaison ordinaire s'arrête au premier caractère différent et laisse fuiter de l'information par le temps de réponse.

Répondez 403 si la signature ne passe pas, sans autre détail.

Endpoint complet
public function recevoir(Request $requete)
{
    if (! $this->signatureValide($requete)) {
        return response()->json(['error' => 'invalid_signature'], 403);
    }

    $message = $requete->input('message', []);
    $wamid   = data_get($message, 'wamid');

    // Déduplication : la Gateway peut livrer deux fois
    if ($wamid && ! Cache::add('wa:' . hash('sha256', $wamid), true, 600)) {
        return response()->json(['status' => 'duplicate']);
    }

    // Traitement en tâche de fond, jamais dans la requête
    TraiterMessage::dispatch(
        data_get($message, 'from'),
        data_get($message, 'selection.id') ?? data_get($message, 'text')
    );

    return response()->json(['status' => 'ok']);
}

private function signatureValide(Request $requete): bool
{
    $signature = $requete->header('X-Gateway-Signature');
    $timestamp = $requete->header('X-Gateway-Timestamp');
    $secret    = config('gateway.callback_secret');

    if (blank($signature) || blank($timestamp) || blank($secret)) {
        return false;
    }

    // Anti-rejeu
    if (abs(now()->timestamp - (int) $timestamp) > 300) {
        return false;
    }

    $attendu = 'sha256=' . hash_hmac(
        'sha256',
        $timestamp . '.' . $requete->getContent(),
        $secret
    );

    return hash_equals($attendu, $signature);
}
// pubspec.yaml : crypto: ^3.0.0
import 'dart:convert';
import 'package:crypto/crypto.dart';

bool signatureValide({
  required String corpsBrut,
  required String? signature,
  required String? timestamp,
  required String secret,
}) {
  if (signature == null || timestamp == null) return false;

  // Anti-rejeu : 5 minutes
  final maintenant = DateTime.now().millisecondsSinceEpoch ~/ 1000;
  final envoi = int.tryParse(timestamp);

  if (envoi == null || (maintenant - envoi).abs() > 300) {
    return false;
  }

  final hmac = Hmac(sha256, utf8.encode(secret));
  final calcule = hmac.convert(
    utf8.encode('$timestamp.$corpsBrut'),
  );

  final attendu = 'sha256=${calcule.toString()}';

  // Comparaison en temps constant
  if (attendu.length != signature.length) return false;

  var diff = 0;
  for (var i = 0; i < attendu.length; i++) {
    diff |= attendu.codeUnitAt(i) ^ signature.codeUnitAt(i);
  }

  return diff == 0;
}
const crypto = require('crypto');

// Conserver le corps brut au passage
app.use(express.json({
  verify: (req, res, buf) => { req.corpsBrut = buf.toString(); },
}));

app.post('/whatsapp/callback', (req, res) => {
  const signature = req.get('X-Gateway-Signature');
  const timestamp = req.get('X-Gateway-Timestamp');
  const secret = process.env.GATEWAY_CALLBACK_SECRET;

  if (!signature || !timestamp) {
    return res.status(403).json({ error: 'invalid_signature' });
  }

  // Anti-rejeu
  const maintenant = Math.floor(Date.now() / 1000);
  if (Math.abs(maintenant - Number(timestamp)) > 300) {
    return res.status(403).json({ error: 'expired' });
  }

  const attendu = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${req.corpsBrut}`)
    .digest('hex');

  const valide =
    attendu.length === signature.length &&
    crypto.timingSafeEqual(
      Buffer.from(attendu),
      Buffer.from(signature)
    );

  if (!valide) {
    return res.status(403).json({ error: 'invalid_signature' });
  }

  res.json({ status: 'ok' });            // répondre d'abord
  traiterEnArrierePlan(req.body.message);   // traiter ensuite
});
import hashlib, hmac, time
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = os.environ["GATEWAY_CALLBACK_SECRET"]


def signature_valide(corps_brut: bytes) -> bool:
    signature = request.headers.get("X-Gateway-Signature")
    timestamp = request.headers.get("X-Gateway-Timestamp")

    if not signature or not timestamp:
        return False

    # Anti-rejeu : 5 minutes
    if abs(time.time() - int(timestamp)) > 300:
        return False

    attendu = "sha256=" + hmac.new(
        SECRET.encode(),
        f"{timestamp}.".encode() + corps_brut,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(attendu, signature)


@app.post("/whatsapp/callback")
def recevoir():
    if not signature_valide(request.get_data()):
        return jsonify(error="invalid_signature"), 403

    message = request.json.get("message", {})
    entree = (message.get("selection") or {}).get("id") \
        or message.get("text")

    traiter_en_tache_de_fond(message.get("from"), entree)

    return jsonify(status="ok")

Quatre règles pour votre endpoint

Répondez 200 en moins de 8 secondes

Au-delà, la Gateway considère la livraison en échec et retente. Le délai n'est pas négociable.

Traitez en tâche de fond

Ne faites jamais tourner votre logique métier dans le cycle de la requête. Accusez réception, mettez le travail en file d'attente, traitez ensuite. Un appel à un modèle de langage ou une requête lourde en base suffit à dépasser le délai.

Soyez idempotent

La Gateway peut livrer deux fois le même message. Utilisez le wamid comme clé de déduplication, sinon une seule demande produira deux commandes.

Vérifiez la signature

Systématiquement, sur chaque requête. Voir la section précédente.

Que se passe-t-il si vous ne répondez pas

La Gateway retente jusqu'à cinq fois, avec un délai croissant :

15 s→60 s→5 min→15 min

Une application redémarrée ou en maintenance récupère donc ses messages à son retour, dans une fenêtre d'environ vingt minutes.

Le bon réflexe
// Répondre vite, traiter après

TraiterMessage::dispatch($expediteur, $entree);

return response()->json(['status' => 'ok']);
À éviter
// Le pipeline tourne dans la requête.
// Au-delà de 8 s, la Gateway abandonne
// et rejoue le message.

$reponse = $this->chatbot->traiter($entree);
$this->gateway->envoyerTexte($expediteur, $reponse);

return response()->json(['status' => 'ok']);
Déduplication
// Une clé par wamid, gardée 10 minutes.
// Cache::add est atomique : il échoue
// si la clé existe déjà.

if (! Cache::add('wa:' . $wamid, true, 600)) {
    return response()->json(['status' => 'duplicate']);
}

Un cache en mémoire volatile ne déduplique rien : il disparaît à la fin de chaque requête, et chaque doublon passe. Utilisez un cache partagé entre les processus.

Statuts d'un message

pending→sent→delivered→read oufailed
StatutSignification
pendingAccepté par la Gateway, pas encore transmis
sentTransmis à WhatsApp
deliveredArrivé sur le téléphone
readLu par le destinataire
failedÉchec définitif, détail dans error

Un statut ne régresse jamais. WhatsApp remonte parfois delivered après read : l'horodatage est enregistré, mais le statut affiché reste le plus avancé.

Erreurs

Toutes les erreurs partagent le même format : un champ error lisible par votre code, un champ message lisible par un humain.

Erreurs de la Gateway

CodeerrorCause
401unauthorizedEn-têtes absents ou invalides
403forbiddenApplication désactivée
422no_active_numberAucun numéro rattaché
422—Validation, détail dans errors
429—Quota dépassé

Erreurs WhatsApp, renvoyées en 502

CodeCauseQue faire
131047Fenêtre de 24 h ferméePasser par un template
132001Modèle inconnu ou mauvaise langueVérifier le nom et le code
131026Le destinataire n'a pas WhatsAppPrévoir un canal de repli
190Token expiréPrévenir l'administrateur
131005Accès refuséPrévenir l'administrateur

Une 502 ne vient ni de votre code ni de la Gateway : c'est WhatsApp qui refuse. Le message d'origine est transmis tel quel pour que vous puissiez agir.

Quand la Gateway ne répond pas

Traitez l'indisponibilité comme une panne temporaire, jamais comme une erreur fatale. Votre application doit se dégrader proprement — file d'attente, message de repli, nouvelle tentative plus tard.

Format d'une erreur
{
  "error": "no_active_number",
  "message": "Aucun numéro WhatsApp actif n'est associé à cette application."
}
Erreur de validation
{
  "message": "Le numéro doit être au format E.164, par exemple +221771234567.",
  "errors": {
    "to": ["Le numéro doit être au format E.164, par exemple +221771234567."]
  }
}
Erreur WhatsApp
{
  "id": "e9c1fb4e-b3d3-4bec-848a-8d1069d71b97",
  "status": "failed",
  "to": "+221771234567",
  "sent_at": null,
  "error": {
    "code": "131047",
    "message": "Re-engagement message"
  }
}
Gérer proprement
$resultat = $gateway->envoyerTexte($vers, $texte);

if (data_get($resultat, 'error.code') === '131047') {
    // Hors fenêtre : basculer sur un template
    return $gateway->envoyerTemplate(
        $vers,
        'relance_conversation'
    );
}

Limites et référence

MéthodeEndpointAuthentification
GET/api/v1/health—
GET/api/v1/merequise
POST/api/v1/messages/textrequise
POST/api/v1/messages/templaterequise
POST/api/v1/messages/interactiverequise
GET/api/v1/messages/{id}requise
POST/api/v1/otp/sendrequise
POST/api/v1/otp/verifyrequise

Plafonds

LimiteValeur
Requêtes60 par minute et par application
Texte4096 caractères
Corps interactif1024 caractères
Options10 maximum
Titre de bouton20 caractères
Titre de liste24 caractères
Description72 caractères
En-tête et pied60 caractères

Les titres trop longs sont tronqués, pas rejetés. Écrivez court plutôt que de compter les caractères.