Ne jamais envoyer ni facturer deux fois le même message (idempotency_key, retry sûr)

Oui pour les templates : ajoutez idempotency_key (128 caractères max) dans le corps de POST /v1/messages ; le rejeu renvoie le message d'origine sans renvoyer ni redébiter. Non pour reply et interactive, qui n'ont pas cette clé.

Ne jamais envoyer ni facturer deux fois le même message (idempotency_key, retry sûr)

Astuce
Oui pour les templates : ajoutez idempotency_key (128 caractères max) dans le corps de POST /v1/messages ; le rejeu renvoie le message d'origine sans renvoyer ni redébiter. Non pour reply et interactive, qui n'ont pas cette clé.

Scénario

Votre serveur envoie un reçu de paiement. L'appel met 12 secondes, votre client HTTP abandonne et réessaie. Sans protection, le client reçoit deux reçus et vous payez deux fois. Oui, il existe une protection : idempotency_key. Un second appel avec la même clé ne renvoie pas de message : il retourne le résultat du premier.

Comment ça marche

Appel 1   POST /v1/messages  idempotency_key="recu-8842"   → envoyé, 1 débit, id=9d3e…
(coupure réseau, votre client réessaie)
Appel 2   POST /v1/messages  idempotency_key="recu-8842"   → renvoie id=9d3e…, rien renvoyé, rien débité

curl

curl -X POST "https://wa-api.geskap.com/v1/messages" \
  -H "Authorization: Bearer $GESKAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "to": "+237690000001",
  "idempotency_key": "recu-8842",
  "template": {
    "name": "recu_paiement",
    "language": "fr",
    "variables": ["Awa", "15 000 FCFA"]
  }
}'

Node.js

const res = await fetch("https://wa-api.geskap.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.GESKAP_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "to": "+237690000001",
    "idempotency_key": "recu-8842",
    "template": {
      "name": "recu_paiement",
      "language": "fr",
      "variables": ["Awa", "15 000 FCFA"]
    }
  }),
});
console.log(res.status, await res.json());

PHP

$ch = curl_init("https://wa-api.geskap.com/v1/messages");
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => "POST",
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("GESKAP_API_KEY"),
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "to" => "+237690000001",
    "idempotency_key" => "recu-8842",
    "template" => [
      "name" => "recu_paiement",
      "language" => "fr",
      "variables" => ["Awa", "15 000 FCFA"],
    ],
  ]),
]);
$res = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $res;

Python

import os, requests

res = requests.request(
    "POST",
    "https://wa-api.geskap.com/v1/messages",
    headers={"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"},
    json={
        "to": "+237690000001",
        "idempotency_key": "recu-8842",
        "template": {
            "name": "recu_paiement",
            "language": "fr",
            "variables": ["Awa", "15 000 FCFA"],
        },
    },
    timeout=15,
)
print(res.status_code, res.json())

La clé va dans le corps JSON de la requête, à côté de to et de template. Ce n'est pas un en-tête HTTP : un en-tête Idempotency-Key est ignoré. Elle fait 128 caractères au plus.

Ce que le rejeu renvoie

Le même id, le même status, le même wa_message_id, le même credits_charged. credits_remaining n'y figure pas (rien n'a été débité) et l'en-tête X-Credits-Remaining n'est pas posé : ne vous en servez pas pour reconnaître un rejeu.

Choisir la clé

Une clé par message logique, jamais par tentative. Prenez un identifiant de votre métier : recu-8842, rdv-1031-rappel-veille, commande-557-livree. Générer une valeur au hasard à chaque tentative annule la protection.

Réessayer en toute sécurité

Réessayez avec la même clé après un délai d'attente, une coupure ou une erreur serveur. Si l'envoi a échoué pour de bon (402 crédits insuffisants, ou 502 refus de Meta), la clé est libérée : le même appel avec la même clé repart normalement, par exemple après avoir rechargé.

SituationCe que vous obtenez
Premier appel réussi, rejeuLe message d'origine, sans renvoi ni débit
Deux appels simultanés avec la même cléUn seul envoi ; l'autre reçoit le résultat du premier, parfois avec status: "pending"
Premier appel refusé (402, 502)La clé est libérée, le rejeu est un vrai nouvel essai

Et pour les réponses libres et interactives ?

POST /v1/messages/reply et POST /v1/messages/interactive n'ont pas de champ idempotency_key. Un rejeu envoie un second message. Dédoublonnez de votre côté : gardez le wa_message_id du message entrant auquel vous répondez, et ne répondez qu'une fois par message reçu (voir Archiver messages et médias).

Attention
Piège : la clé retrouve le message d'origine sans comparer le contenu. Réutiliser recu-8842 pour un autre destinataire ou un autre template renvoie le premier envoi, sans rien envoyer de nouveau. Une clé = un message.
Attention
Piège : l'idempotence ne couvre que votre propre rejeu. Elle ne dédoublonne pas deux événements différents de votre système qui produisent deux clés différentes pour le même fait : construisez la clé à partir du fait (la commande), pas de l'événement technique.

Par le connecteur MCP, deux envois identiques dans les 5 minutes sont déjà regroupés automatiquement, et une clé explicite l'emporte. Pour le coût d'un envoi réussi, voir Coût d'un message.

Preuve — vérifié dans le code de l'API : api_schemas.py, api_send.py, api_usage.py, 040_dev_api_usage.sql, mcp_server.py.