API WhatsApp
Créez vos modèles WhatsApp, envoyez-les une fois approuvés depuis votre logiciel et suivez leur livraison avec la même clé API que vos SMS. L’API demande un abonnement WhatsApp Coussema actif et votre propre numéro WhatsApp Business ; Meta facture les messages sur le moyen de paiement de votre compte WhatsApp Business.
Avant le premier envoi
- Un abonnement WhatsApp Coussema actif (mensuel ou essai). Sans abonnement actif, l’envoi est refusé :
402 Payment Required, codewhatsapp_subscription_inactive(WA402). - Votre numéro WhatsApp Business, connecté par vous depuis l’espace WhatsApp Coussema. Chaque organisation envoie depuis ses propres numéros.
- Un moyen de paiement valide sur votre compte WhatsApp Business : Meta vous facture directement les messages, Coussema facture l’abonnement.
- Une clé API avec les droits
whatsapp:readetwhatsapp:send, créée dans l’espace Développeurs. Ces droits ne sont accordés qu’à une organisation dont un numéro WhatsApp est connecté. - Un modèle approuvé par Meta, de catégorie
utility(notification),marketing(promotion) ouauthentication(code de vérification). Créez-le depuis l’espace WhatsApp ou avecPOST /v1/whatsapp/templates(voir « Créer un modèle »). - Un destinataire qui n’a pas arrêté vos messages. Coussema ne demande pas d’accord enregistré pour un envoi à une personne : comme le prévoient les règles de WhatsApp, vous restez responsable d’avoir son accord. Un destinataire qui a répondu STOP, ou dont l’accord a été retiré pour la catégorie du modèle, est refusé (
400,whatsapp_opt_in_required). Vous pouvez enregistrer un accord avecPOST /v1/whatsapp/opt-ins(numéro, catégories, preuve) et un retrait avecPOST /v1/whatsapp/opt-ins/opt-out; START réinscrit pour les modèles utilitaires.
Créer un modèle
POST /v1/whatsapp/templates soumet directement votre modèle à Meta, sur le compte WhatsApp Business de votre numéro. Rien ne passe par l’équipe Coussema : Meta l’examine, souvent en quelques minutes, parfois jusqu’à 24 heures.
Écrivez le texte dans message avec {{1}}, {{2}}… pour les parties qui changent à chaque envoi, dans l’ordre, et donnez un exemple réaliste de chacune dans examples, dans le même ordre. Le message ne commence ni ne finit par une partie personnalisée, et deux parties sont séparées par au moins un mot. À l’envoi, vous remplissez ces parties dans template.parameters, dans le même ordre.
En haut, au choix : title (une ligne, 60 caractères) ou media (une image JPEG ou PNG jusqu’à 5 Mo, une vidéo MP4 ou un document PDF jusqu’à 16 Mo) : { "type": "image", "url": "https://…" }. Le lien public en https sert d’exemple à Meta, et Coussema garde une copie du fichier, envoyée à chaque message sauf si l’envoi donne un autre lien dans template.media.url.
footer (une mention en bas, 60 caractères) et buttons sont facultatifs. Boutons : quick_reply (réponse rapide), website (lien, deux au plus) et call (appel, un seul), 25 caractères de texte chacun, dix au plus. Un lien personnalisé finit par {{1}} (https://exemple.com/commandes/{{1}}) avec un example de sa fin (4471) : à l’envoi, donnez la fin de chaque lien personnalisé dans template.urlParameters, dans l’ordre des boutons.
category vaut utility (notification : facture, livraison, rendez-vous, suivi), marketing (promotion : offres, nouveautés, relances) ou authentication (code de vérification). Meta classe en promotion tout modèle qui mêle notification et offre. Une promotion demande l’accord du destinataire pour la catégorie marketing. Pour un code, WhatsApp écrit le texte lui-même : vous ne choisissez que verificationCode (mention de sécurité, durée de validité de 1 à 90 minutes, texte du bouton). À l’envoi, le code se met dans template.parameters ; Coussema remplit aussi le bouton « Copier le code ».
Le couple name + language identifie le modèle : name en minuscules, chiffres et _, language au format Meta (fr, en, sw, pt_PT, pt_BR, ar…). Une seconde demande pour un modèle en revue ou approuvé est refusée (409) ; pour un modèle refusé, elle le renvoie corrigé en revue sous le même nom.
201 renvoie l’état de la revue de Meta, en général pending. 202 signifie que la réponse de Meta est en cours de confirmation (draft) : relisez le modèle avec GET /v1/whatsapp/templates/{templateId}, ou renvoyez la même demande, sans risque de doublon. Le statut passe ensuite à approved, rejected (avec rejectionReason) ou paused. Meta peut aussi déplacer un modèle dans une autre catégorie : la catégorie renvoyée est alors celle de Meta.
Trois formats de promotion (category: "marketing") en plus du modèle standard : un bouton { "type": "copy_code", "example": "HIVER25" } (code à copier, 20 caractères, un par modèle), une offre limitée offer: { "text": "Offre flash", "expires": true } (accroche de 16 caractères, image ou vidéo en haut, pas de footer, message de 600 caractères, boutons copy_code et website seulement) et un carrousel cards (2 à 10 cartes : une image ou une vidéo chacune, toutes du même type, un message de 160 caractères pour toutes ou aucune, jusqu’à 2 boutons identiques d’une carte à l’autre). Coussema garde les fichiers des cartes comme celui du haut.
À l’envoi : template.couponCode (le code à copier), template.offerExpiresAt (la fin de l’offre, ISO 8601, quand elle s’affiche) et template.cards dans l’ordre des cartes (parameters, urlParameters, et media.url pour remplacer le fichier gardé). Une campagne donne le même code et la même fin à tous ses destinataires ; ses cartes ne peuvent pas avoir de partie personnalisée.
Si vos numéros sont sur plusieurs comptes WhatsApp Business, indiquez phoneNumberId (identifiant Coussema du numéro) pour choisir le compte.
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "invoice_ready",
"language": "fr",
"category": "utility",
"title": "Votre facture",
"message": "Bonjour {{1}}, votre facture {{2}} est disponible.",
"examples": ["Mireille", "F-2026-114"],
"footer": "Atlas Logistics",
"buttons": [
{ "type": "website", "text": "Voir la facture", "url": "https://example.com/invoices" },
{ "type": "quick_reply", "text": "Merci" }
]
}'
201 Created
{
"template": {
"id": "6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10",
"name": "invoice_ready",
"language": "fr",
"category": "utility",
"status": "pending",
"rejectionReason": null,
"createdAt": "2026-10-25T10:00:00.000Z",
"updatedAt": "2026-10-25T10:00:00.000Z"
}
}# A promotion with an image at the top and a personalized link
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "weekend_offer",
"language": "fr",
"category": "marketing",
"media": { "type": "image", "url": "https://example.com/images/collection.jpg" },
"message": "Bonjour {{1}}, -20 % ce week-end sur la nouvelle collection.",
"examples": ["Mireille"],
"buttons": [
{ "type": "website", "text": "Voir l’offre", "url": "https://example.com/offers/{{1}}", "example": "red-dress" },
{ "type": "quick_reply", "text": "Plus de promotions" }
]
}'
# Once approved, send it: the kept image goes with it, the link ends with each customer's value
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: weekend-offer-243810000000' \
-d '{
"type": "template",
"to": "+243810000000",
"phoneNumberId": "11111111-1111-4111-8111-111111111111",
"template": {
"name": "weekend_offer",
"language": "fr",
"category": "marketing",
"parameters": ["Mireille"],
"urlParameters": ["red-dress"]
}
}'# A limited-time offer with a code to copy
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "flash_sale",
"language": "fr",
"category": "marketing",
"media": { "type": "image", "url": "https://example.com/images/flash.jpg" },
"offer": { "text": "Offre flash", "expires": true },
"message": "Bonjour {{1}}, -20 % sur tout jusqu’à ce soir.",
"examples": ["Mireille"],
"buttons": [
{ "type": "copy_code", "example": "FLASH20" },
{ "type": "website", "text": "J’en profite", "url": "https://example.com/flash" }
]
}'
# Its send gives the code and the end of the offer
# "template": { "name": "flash_sale", "language": "fr", "category": "marketing",
# "parameters": ["Mireille"], "couponCode": "FLASH20",
# "offerExpiresAt": "2026-11-28T23:59:00+01:00" }
# A carousel: the message, then 2 to 10 cards of the same shape
curl -X POST https://api.coussema.com/v1/whatsapp/templates \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "new_collection",
"language": "fr",
"category": "marketing",
"message": "La nouvelle collection est arrivée.",
"cards": [
{ "media": { "type": "image", "url": "https://example.com/images/red.jpg" },
"message": "Robe rouge, -20 %",
"buttons": [{ "type": "website", "text": "Voir", "url": "https://example.com/red" }] },
{ "media": { "type": "image", "url": "https://example.com/images/blue.jpg" },
"message": "Robe bleue, -20 %",
"buttons": [{ "type": "website", "text": "Voir", "url": "https://example.com/blue" }] }
]
}'curl -X POST https://api.coussema.com/v1/whatsapp/templates \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "login_code",
"language": "fr",
"category": "authentication",
"verificationCode": { "securityNotice": true, "expiresInMinutes": 10 }
}'
# Read Meta's review again:
curl https://api.coussema.com/v1/whatsapp/templates/6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10 \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx'Envoyer un modèle
POST /v1/whatsapp/messages est asynchrone. 202 Accepted signifie que Coussema a validé la requête, enregistré le message et l’a placé dans la file d’envoi ; la transmission à Meta se fait ensuite. Conservez messageId : provider.messageId vaut toujours null dans cette réponse.
phoneNumberId est l’identifiant Coussema de votre numéro, lu avec GET /v1/whatsapp/phone-numbers, et non l’identifiant Meta.
Envoyez un Idempotency-Key unique par message. Rejouer la même clé avec le même contenu renvoie le même message au lieu d’en créer un nouveau ; la même clé avec un contenu différent est refusée.
Une réponse libre (texte, fichier, boutons, liste ou lieu, avec conversationId) n’est acceptée que dans une conversation ouverte par votre client, pendant la fenêtre de service de 24 heures : voir « Répondre dans la fenêtre de 24 heures ».
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: invoice-12345-wa' \
-d '{
"type": "template",
"to": "+243810000000",
"phoneNumberId": "11111111-1111-4111-8111-111111111111",
"template": {
"name": "payment_notice",
"language": "fr",
"category": "utility",
"parameters": ["12345"]
},
"clientReference": "invoice-12345"
}'
202 Accepted
{
"success": true,
"queued": true,
"status": "accepted",
"messageId": "a2f7e5cb-bbe9-4d7d-bd17-80d1d7a5e5af",
"provider": { "code": "whatsapp", "messageId": null, "httpStatus": 202 }
}const apiKey = process.env.COUSSEMA_API_KEY ?? '';
const response = await fetch('https://api.coussema.com/v1/whatsapp/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'invoice-12345-wa',
},
body: JSON.stringify({
type: 'template',
to: '+243810000000',
phoneNumberId: '11111111-1111-4111-8111-111111111111',
template: { name: 'payment_notice', language: 'fr', category: 'utility', parameters: ['12345'] },
clientReference: 'invoice-12345',
}),
});
const body = await response.json();
if (response.status === 402) throw new Error(body.error); // abonnement WhatsApp inactif
if (!response.ok) throw new Error(`WhatsApp request failed: ${response.status}`);
console.log(body.messageId);Répondre dans la fenêtre de 24 heures
Quand votre client vous écrit, vous pouvez lui répondre librement pendant 24 heures, sans modèle. Donnez le conversationId de sa conversation (GET /v1/whatsapp/conversations ou le webhook whatsapp.message.received) avec le même to et le même phoneNumberId. Après 24 heures, la réponse est refusée (400, whatsapp_service_window_closed) : envoyez un modèle approuvé.
type: "text" : un texte de 4 096 caractères (text).
type: "image", "video", "document" ou "audio" : le fichier par un lien public en https dans media.url, que Meta télécharge à l’envoi (image JPEG ou PNG jusqu’à 5 Mo, vidéo MP4 et audio jusqu’à 16 Mo, document jusqu’à 100 Mo selon Meta). media.caption (1 024 caractères) pour une image, une vidéo ou un document ; media.filename pour le nom du document. Un lien illisible par Meta fait passer le message en failed.
type: "interactive" : jusqu’à 3 boutons de réponse (interactive.type: "button", bodyText, buttons avec id et title de 20 caractères) ou une liste (interactive.type: "list", buttonText, sections et rows, 10 lignes au plus, un titre par section quand il y en a plusieurs). footerText est facultatif. La réponse du client arrive avec le webhook whatsapp.message.received.
type: "location" : un lieu sur la carte, location.latitude et location.longitude, avec name et address facultatifs.
Ces réponses sont asynchrones comme un modèle (202, messageId), idempotentes par Idempotency-Key et demandent le droit whatsapp:send. GET /v1/whatsapp/messages/{messageId} renvoie leur messageType : text, image, video, document, audio, interactive ou location.
# Inside the 24-hour window of a conversation your customer opened:
# the same route, the same Idempotency-Key rule, a 202 with the Coussema message id.
# A document by its public https link (image and video: the same, with a caption)
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: facture-2231-pdf' \
-d '{
"type": "document",
"to": "+243810000000",
"phoneNumberId": "11111111-1111-4111-8111-111111111111",
"conversationId": "22222222-2222-4222-8222-222222222222",
"media": {
"url": "https://example.com/factures/F-2231.pdf",
"filename": "Facture-F-2231.pdf",
"caption": "Votre facture"
}
}'
# Up to 3 reply buttons
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: rdv-5512-confirm' \
-d '{
"type": "interactive",
"to": "+243810000000",
"phoneNumberId": "11111111-1111-4111-8111-111111111111",
"conversationId": "22222222-2222-4222-8222-222222222222",
"interactive": {
"type": "button",
"bodyText": "Confirmez-vous le rendez-vous de demain à 10 h ?",
"buttons": [{ "id": "confirm", "title": "Je confirme" }, { "id": "move", "title": "Changer" }]
}
}'
# A list (up to 10 rows)
# "interactive": { "type": "list", "bodyText": "Choisissez un créneau.", "buttonText": "Créneaux",
# "sections": [{ "title": "Lundi", "rows": [{ "id": "mon-9", "title": "9 h", "description": "Gombe" }] }] }
# A place
# "type": "location",
# "location": { "latitude": -4.3217, "longitude": 15.3125, "name": "Agence Gombe", "address": "Boulevard du 30 Juin" }const apiKey = process.env.COUSSEMA_API_KEY ?? '';
// conversationId: from GET /v1/whatsapp/conversations or the whatsapp.message.received webhook.
async function reply(message, idempotencyKey) {
const response = await fetch('https://api.coussema.com/v1/whatsapp/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
to: '+243810000000',
phoneNumberId: '11111111-1111-4111-8111-111111111111',
conversationId: '22222222-2222-4222-8222-222222222222',
...message,
}),
});
const body = await response.json();
if (!response.ok) throw new Error(`${response.status} ${body.code ?? ''}`); // whatsapp_service_window_closed : envoyez un modèle
return body.messageId;
}
await reply({ type: 'image', media: { url: 'https://example.com/colis.jpg', caption: 'Votre colis est parti' } }, 'colis-4471-photo');
await reply({ type: 'location', location: { latitude: -4.3217, longitude: 15.3125, name: 'Agence Gombe' } }, 'colis-4471-agence');Campagnes multicanales par API
POST /v1/whatsapp/campaigns crée depuis votre logiciel la même campagne que le tableau de bord : un modèle approuvé, jusqu’à 2 000 numéros par requête (découpez une liste plus grande), envoyée tout de suite ou à scheduledAt, étalée si besoin avec spreadMinutes. Chaque valeur personnalisée compte au plus 200 caractères ; une requête pèse au plus 5 Mo.
Chaque numéro donne ses values dans l’ordre du modèle : {{1}}, {{2}}… du message, puis la fin de chaque lien personnalisé. defaults donne un texte par partie, utilisé quand une valeur est vide ; il est requis dès que le modèle a des parties personnalisées.
Le consentement suit les règles du tableau de bord : un numéro sans accord enregistré pour la catégorie du modèle est écarté (missing_opt_in). Avec declaredConsent: true, vous déclarez l’accord de ces numéros ; il est enregistré comme accord, avec votre clé pour auteur, avant la création.
Relais SMS (smsRelay) : les numéros que Meta signale sans WhatsApp reçoivent un SMS, une seule fois par numéro, depuis un Sender ID validé de votre compte. Sans smsTemplateId, le SMS reprend le texte du modèle WhatsApp ; avec un modèle SMS, ses champs prennent les valeurs WhatsApp du même nom, et un champ absent du modèle WhatsApp prend la valeur donnée dans smsRelay.fieldValues (la même pour toute la campagne, 60 caractères, 10 champs au plus). Un carrousel exige un modèle SMS. Les SMS sont décomptés de votre solde SMS ; les messages WhatsApp restent facturés par Meta sur votre carte. La clé doit avoir aussi le droit sms:send.
dryRun: true vérifie tout (modèle, valeurs, date, relais) et renvoie les numéros acceptés et écartés, et le nombre maximal de SMS du relais, sans rien créer : c’est la façon de valider une liste avant de l’envoyer. Une création exige un Idempotency-Key (ou clientReference) : la même clé renvoie la même campagne, jamais une deuxième.
Suivez la campagne avec GET /v1/whatsapp/campaigns/{campaignId} (comptes d’envoi, de lecture et du relais SMS) et les webhooks des messages WhatsApp et SMS.
curl -X POST https://api.coussema.com/v1/whatsapp/campaigns \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: rappels-2026-10-29' \
-d '{
"name": "Rappel fin d’abonnement",
"templateId": "6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10",
"recipients": [
{ "phone": "+243810000000", "values": ["Mireille", "31/10/2026"] },
{ "phone": "+243970000000", "values": ["Jean", "02/11/2026"] }
],
"defaults": ["cher client", "bientôt"],
"smsRelay": {
"senderId": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
"smsTemplateId": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
"fieldValues": { "Bouquet": "Famille" }
}
}'
202 Accepted
{
"dryRun": false,
"campaign": {
"id": "d4c3b2a1-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
"name": "Rappel fin d’abonnement",
"status": "scheduled",
"scheduledAt": "2026-10-29T10:00:00.000Z",
"createdAt": "2026-10-29T10:00:00.000Z"
},
"recipients": { "requested": 2, "accepted": 2, "excluded": 0, "excludedRecipients": [] },
"smsRelay": { "senderName": "MONENTREPRISE", "smsTemplateName": "rappel_sms", "maxSmsUnits": null }
}
# Follow it (the counts of the SMS relay included):
curl https://api.coussema.com/v1/whatsapp/campaigns/d4c3b2a1-0f9e-4d8c-b7a6-5f4e3d2c1b0a \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx'const apiKey = process.env.COUSSEMA_API_KEY ?? '';
const campaign = {
name: 'Rappel fin d’abonnement',
templateId: '6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10',
recipients: subscribers.map((s) => ({ phone: s.phone, values: [s.firstName, s.endDate] })),
defaults: ['cher client', 'bientôt'],
smsRelay: { senderId: '9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f' },
};
const call = (body, idempotencyKey) => fetch('https://api.coussema.com/v1/whatsapp/campaigns', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify(body),
});
// 1. Check: counts and the most SMS the relay may send, nothing created.
const check = await (await call({ ...campaign, dryRun: true })).json();
console.log(check.recipients.accepted, check.smsRelay?.maxSmsUnits);
// 2. Create: the same key never creates a second campaign.
const response = await call(campaign, 'rappels-2026-10-29');
if (!response.ok) throw new Error(`WhatsApp campaign failed: ${response.status}`);
const { campaign: created } = await response.json();
console.log(created.id);Multicanal par API
Pour un code ou une notification, WhatsApp d’abord coûte moins cher qu’un SMS, et Meta dit de façon fiable quand un numéro n’a pas WhatsApp. Deux options l’utilisent, avec le même moteur : le SMS ne part que si Meta signale que le numéro n’est pas sur WhatsApp (erreur 131026), une seule fois par message, depuis un Sender ID validé de votre compte, en 4 SMS au plus.
Message WhatsApp avec SMS de secours : ajoutez smsFallback à un envoi de modèle sur POST /v1/whatsapp/messages. Sans autre choix, le SMS reprend le texte du modèle WhatsApp avec ses valeurs (pour un code, « 482913 est votre code de vérification. ») ; smsTemplateId envoie un de vos modèles SMS, text un texte à vous. Un [Champ] prend la valeur du champ WhatsApp du même nom, sinon celle de fieldValues. La clé doit avoir aussi le droit sms:send, et la requête un Idempotency-Key (ou clientReference).
SMS « WhatsApp d’abord » : ajoutez whatsappFirst (phoneNumberId, templateId, fieldValues par nom de champ ou par place) à POST /v1/sms/send. Le modèle WhatsApp approuvé part d’abord ; votre SMS part, tel quel et depuis le même expéditeur, seulement si le numéro n’a pas WhatsApp. La réponse 202 (channel: "whatsapp_first") donne whatsapp.messageId ; sms.messageId vaut null tant que le SMS n’existe pas. La clé doit avoir aussi le droit whatsapp:send. Le solde SMS est vérifié dès la demande, comme pour tout SMS de l’API.
Suivi : GET /v1/whatsapp/messages/{messageId} donne relay : requested, status (none tant qu’aucun SMS n’est décidé, queued quand le SMS est en file, refused sinon), reason (sms_balance_insufficient, sender_unavailable, recipient_not_covered, organization_inactive, relay_unavailable) et sms (messageId, statut de livraison). Le SMS envoie aussi ses événements message.* à vos webhooks SMS.
Ce qui ne relaie pas : un WhatsApp envoyé mais pas encore remis (téléphone éteint, pas de données), un refus de Meta pour une autre raison (paiement, limite de promotions, personne qui a arrêté vos promotions), un numéro hors +243, un message de plus de 24 heures. Le SMS de secours couvre les numéros +243 seulement.
Facturation : le SMS est décompté de votre solde SMS au moment de son envoi, selon son nombre de SMS ; un SMS parti puis non remis par l’opérateur n’est pas remboursé. Les messages WhatsApp restent facturés par Meta sur le moyen de paiement de votre compte WhatsApp Business.
curl -X POST https://api.coussema.com/v1/whatsapp/messages \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: login-482913' \
-d '{
"type": "template",
"to": "+243810000000",
"phoneNumberId": "11111111-1111-4111-8111-111111111111",
"template": { "name": "code_connexion", "language": "fr", "category": "authentication", "parameters": ["482913"] },
"smsFallback": { "senderId": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f" }
}'
# 202 Accepted: the WhatsApp message. Its SMS leaves only if Meta reports the number is not on WhatsApp:
curl https://api.coussema.com/v1/whatsapp/messages/8f0aa7ed-1b2c-4d3e-8f4a-5b6c7d8e9f01 \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx'
# "relay": { "requested": true, "status": "queued", "reason": null,
# "sms": { "messageId": "2fc1be26-…", "status": "DELIVERED" } }curl -X POST https://api.coussema.com/v1/sms/send \
-H 'Authorization: Bearer csm_live_xxxxxxxxxxxx_xxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: login-482913-sms' \
-d '{
"to": "243810000000",
"message": "Votre code de vérification est 482913.",
"senderId": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
"whatsappFirst": {
"phoneNumberId": "11111111-1111-4111-8111-111111111111",
"templateId": "6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10",
"fieldValues": { "Code": "482913" }
}
}'
# 202 Accepted
{
"success": true, "queued": true, "status": "accepted", "channel": "whatsapp_first", "count": 1,
"remainingQuota": 4210,
"whatsapp": { "messageId": "8f0aa7ed-1b2c-4d3e-8f4a-5b6c7d8e9f01" },
"sms": { "messageId": null, "billableUnits": 1 },
"clientReference": null,
"provider": { "code": "coussema", "messageId": null, "httpStatus": 202 }
}// On your server (Node 18+): one login code, WhatsApp first, SMS if the number is not on WhatsApp.
const apiKey = process.env.COUSSEMA_API_KEY;
const send = (path, body, idempotencyKey) => fetch(`https://api.coussema.com${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(body),
});
const code = '482913';
const response = await send('/v1/sms/send', {
to: '243810000000',
message: `Votre code de vérification est ${code}.`,
senderId: '9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f',
whatsappFirst: {
phoneNumberId: '11111111-1111-4111-8111-111111111111',
templateId: '6b1f3c2e-8a54-4d1e-9f0a-2c7d5e4b3a10',
fieldValues: { Code: code },
},
}, `login-${code}`); // the same key never sends twice
const body = await response.json();
if (!response.ok) throw new Error(body.error);
// Later (or on the whatsapp.message.* and message.* webhooks): where did the code arrive?
const status = await fetch(`https://api.coussema.com/v1/whatsapp/messages/${body.whatsapp.messageId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());
console.log(status.message.status, status.message.relay.status, status.message.relay.sms?.status);Endpoints et droits
Les listes sont paginées par curseur et filtrables par statut, catégorie, modèle, destinataire et dates selon la ressource. Les identifiants et erreurs internes de Meta ne sont jamais renvoyés : chaque message échoué porte un publicErrorCode et un failureLabel lisibles.
| Méthode | Route | Droit de la clé |
|---|---|---|
| POST | /v1/whatsapp/messages | whatsapp:send (+ sms:send : smsFallback) |
| GET | /v1/whatsapp/messages | whatsapp:read |
| GET | /v1/whatsapp/messages/{messageId} | whatsapp:read |
| GET | /v1/whatsapp/templates | whatsapp:read |
| POST | /v1/whatsapp/templates | whatsapp:send |
| GET | /v1/whatsapp/templates/{templateId} | whatsapp:read |
| GET | /v1/whatsapp/phone-numbers | whatsapp:read |
| GET | /v1/whatsapp/conversations | whatsapp:read |
| POST | /v1/whatsapp/campaigns | whatsapp:send (+ sms:send : smsRelay) |
| GET | /v1/whatsapp/campaigns/{campaignId} | whatsapp:read |
| POST | /v1/whatsapp/opt-ins | whatsapp:send |
| POST | /v1/whatsapp/opt-ins/opt-out | whatsapp:send |
| GET | /v1/whatsapp/opt-ins | whatsapp:read |
Suivre la livraison
Abonnez un endpoint webhook (clé avec webhooks:write et whatsapp:read) aux événements WhatsApp. Les événements d’un même message arrivent dans l’ordre et sont signés comme les webhooks SMS.
Vous pouvez aussi lire l’état d’un message avec GET /v1/whatsapp/messages/{messageId}.
whatsapp.message.sentwhatsapp.message.deliveredwhatsapp.message.readwhatsapp.message.failedwhatsapp.message.received
Erreurs à gérer
| Statut | Code | Que faire |
|---|---|---|
| 402 | whatsapp_subscription_inactive | Abonnement WhatsApp inactif (WA402) : renouvelez-le, puis renvoyez la requête. |
| 400 | whatsapp_opt_in_required | Le destinataire a arrêté ce type de message (STOP, ou accord retiré pour cette catégorie de modèle). |
| 403 | api_key_scope_required | La clé n’a pas le droit whatsapp:send ou whatsapp:read. |
| 404 | whatsapp_phone_number_not_configured, whatsapp_template_not_configured | Numéro non connecté, ou modèle non approuvé ou d’une catégorie non envoyable. |
| 400 | whatsapp_template_invalid | Modèle refusé avant l’envoi à Meta : le message d’erreur dit quoi corriger (ordre des {{n}}, exemples manquants, partie personnalisée au début ou à la fin, boutons). |
| 409 | whatsapp_template_exists | Un modèle porte déjà ce nom dans cette langue, en revue ou approuvé : choisissez un autre name. |
| 422 | whatsapp_template_rejected | Meta a refusé le modèle à sa création : vérifiez le texte, les exemples et les boutons, puis renvoyez-le. |
| 400 | whatsapp_template_media_invalid | Le fichier d’exemple ne peut pas être lu : lien public en https, type et taille acceptés par WhatsApp. |
| 400 | whatsapp_template_coupon_code_required, whatsapp_template_offer_end_required, whatsapp_template_cards_invalid | L’envoi d’une promotion n’est pas complet : le code à copier, une fin d’offre à venir, ou toutes les cartes du carrousel dans l’ordre. |
| 400 | whatsapp_template_parameters_invalid, whatsapp_template_url_parameters_required, whatsapp_template_media_required | L’envoi ne remplit pas le modèle : autant de parameters que de {{n}}, une fin de lien par lien personnalisé (urlParameters), un media.url pour un modèle à fichier créé hors de Coussema. |
failed | whatsapp_marketing_limited | Meta limite le nombre de promotions qu’une même personne reçoit : le message n’est pas renvoyé automatiquement ; réessayez plus tard. |
failed | whatsapp_marketing_stopped | La personne a arrêté les promotions de votre entreprise dans WhatsApp : Coussema clôt son accord marketing, les promotions suivantes vers ce numéro sont refusées avant l’envoi (whatsapp_opt_in_required) ; ses notifications continuent. |
| 429 | — | Limite de débit atteinte : attendez Retry-After secondes. |
failed | whatsapp_meta_payment_required | Meta a refusé le paiement du message (erreur Meta 131042) : ajoutez ou corrigez le moyen de paiement de votre compte WhatsApp Business. Le message passe en failed et l’événement whatsapp.message.failed est envoyé. |
Limites
- Débit API par organisation, signalé par les en-têtes
X-RateLimit-*; au-delà,429avecRetry-After. - Meta limite chaque numéro selon son palier d’envoi et sa qualité, visibles dans
GET /v1/whatsapp/phone-numbers. - Votre offre WhatsApp inclut un volume de messages par mois ; au-delà, les envois continuent et le surplus est facturé selon l’offre.
- Modèle : jusqu’à 32 paramètres de 1 024 caractères. Texte libre : 4 096 caractères.
clientReference: 128 caractères. - Création de modèles : Meta en accepte 100 par heure et par compte WhatsApp Business.