Documentation API
WhatsApp et SMS sur une seule API : vous choisissez le canal, le template et le numéro, NG Messaging s'occupe du reste.
Démarrage rapide
Toute requête part de la même URL de base https://api.ng-messaging.com. Créez une clé API dans Développeurs → Clés API, puis envoyez un premier message. L'exemple utilise une clé de test : le message part réellement mais n'est jamais facturé.
curl https://api.ng-messaging.com/v1/messages \
-H "X-NG-Api-Key: ngmsg_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"to": "+221771234567",
"body": "Bonjour depuis NG Messaging"
}'WhatsApp : texte libre ou template
Un texte libre WhatsApp n'est accepté que si le destinataire vous a écrit dans les 24 dernières heures (voir Fenêtre de 24 h). Pour écrire en premier, envoyez un template approuvé (voir Templates WhatsApp). Le SMS n'a pas cette contrainte.
La réponse est un 202 Accepted : le message est validé, débité (sauf clé de test) et mis en file avec le statut queued. L'envoi réel se fait ensuite ; suivez le statut par webhook ou avec GET /v1/messages/:id. Chaque réponse porte un en-tête X-Request-Id à communiquer au support.
Réponse à un SMS (clé de test, extrait : le champ unit_price_fcfa est omis) :
{
"id": "8f6c1d9e-3a52-4b7f-9d21-5c0e7b44a1f0",
"channel": "sms",
"type": "simple",
"destination": "+221771234567",
"body": "Votre code est 482913",
"status": "queued",
"cost_fcfa": 0,
"is_test": true,
"gateway_message_id": null,
"error_message": null,
"error_code": null,
"action_required": false,
"correlation_id": null,
"sender": "NG TECH",
"country": "Senegal",
"segments": 1,
"encoding": "GSM-7",
"scheduled_at": null,
"sender_id": null,
"created_at": "2026-10-23T10:00:00"
}Réponse à un message WhatsApp (clé de test) :
{
"id": "5d2b7c40-91e3-4a6f-8c1d-0b7e3f9a2c11",
"channel": "whatsapp",
"type": "simple",
"destination": "+221771234567",
"body": "Bonjour depuis NG Messaging",
"status": "queued",
"cost_fcfa": 0,
"is_test": true,
"gateway_message_id": null,
"error_message": null,
"error_code": null,
"action_required": false,
"correlation_id": null,
"sender": null,
"country": null,
"segments": null,
"encoding": null,
"scheduled_at": null,
"sender_id": null,
"created_at": "2026-10-23T10:00:00"
}cost_fcfa est le montant réellement débité : 0 avec une clé de test. Les champs sender, country, segments, encoding et unit_price_fcfa concernent le SMS (null en WhatsApp) ; sender_id indique le numéro WhatsApp utilisé. gateway_message_id vaut toujours null : l'identifiant à conserver et à communiquer au support est id.
Authentification et clés API
Chaque requête porte votre clé dans l'en-tête X-NG-Api-Key. Une clé absente, mal formée, invalide ou révoquée reçoit 401.
X-NG-Api-Key: ngmsg_live_xxxxxxxxxxxxxxxx| Préfixe | Environnement | Comportement |
|---|---|---|
| ngmsg_test_ | test | Le message part réellement mais n'est jamais facturé (cost_fcfa vaut 0, is_test vaut true). Le volume quotidien est limité : 20 messages par jour par défaut, SMS et WhatsApp confondus, au-delà 429. NG Messaging peut relever cette limite pour votre organisation. |
| ngmsg_live_ | production | Chaque envoi débite votre solde. L'adresse email du propriétaire du compte doit être confirmée, sinon 403. |
Il n'y a pas d'URL distincte pour les tests : le préfixe de la clé suffit. La valeur complète d'une clé n'est affichée qu'une seule fois, à sa création. Ne la placez jamais dans un navigateur ou une application mobile : elle appartient à votre serveur.
Permissions
Une clé est créée avec une liste de permissions. Une requête sans la permission requise reçoit 403, avec le nom de la permission manquante.
| Permission | Type | Ouvre |
|---|---|---|
| sms:send | permission | POST /v1/messages avec channel « sms », POST /v1/sms/estimate, POST /v1/messages/:id/cancel |
| whatsapp:send | permission | POST /v1/messages avec channel « whatsapp », les templates (/v1/templates) et GET /v1/senders |
| otp:send | permission | POST /v1/otp et POST /v1/otp/verify |
| reports | permission | GET /v1/messages, GET /v1/messages/:id, GET /v1/webhooks/deliveries et son rejeu |
| campaigns | permission | Acceptée à la création d'une clé, mais aucune route publique ne l'exige pour le moment. |
| contacts | permission | Acceptée à la création d'une clé, mais aucune route publique ne l'exige pour le moment. |
Clé liée à une application
Une clé peut être rattachée à une application de votre organisation. Elle n'utilise alors que les numéros WhatsApp de cette application pour choisir l'expéditeur (voir Expéditeurs WhatsApp). Une clé sans rattachement peut employer tous les numéros de l'organisation.
Envoyer un message
Un seul endpoint envoie les messages WhatsApp et SMS. Le champ channel décide du reste, et la permission exigée sur la clé suit le canal (whatsapp:send ou sms:send).
| Champ | Type | Description |
|---|---|---|
| channel | string? | « whatsapp » (défaut) ou « sms ». |
| to | string | Numéro au format international, par exemple +221771234567 ou 221771234567. |
| body | string? | Texte du message. Requis pour un message libre, inutile pour un template. |
| type | string? | « text » (défaut) ou « template ». La présence de template suffit à envoyer un template. |
| template | object? | WhatsApp : { name, language, variables }. language vaut « fr » par défaut ; variables est une liste ou un objet, dont les valeurs sont prises dans l'ordre {{1}}, {{2}}…. |
| sender | string? | SMS : signature d'envoi. « NG TECH » ou la signature approuvée de votre compte, sinon 400. Ignoré en WhatsApp. |
| sender_id | string? | WhatsApp : numéro expéditeur (snd_… ou snd_ngtech), voir Expéditeurs WhatsApp. Ignoré en SMS. |
| scheduled_at | datetime? | SMS seulement : heure d'envoi programmée, en ISO 8601 (sans fuseau, l'heure est lue en UTC). Entre 1 minute et 90 jours dans le futur. En WhatsApp, 400. |
| idempotency_key | string? | Équivalent du corps de l'en-tête Idempotency-Key, voir la section Idempotence. |
| metadata | object? | Objet libre. Il n'est pas restitué dans la réponse. |
| image_url / document_url | string? | L'envoi de médias n'est pas disponible : une requête qui en contient reçoit 400. |
curl https://api.ng-messaging.com/v1/messages \
-H "X-NG-Api-Key: ngmsg_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"to": "+221771234567",
"sender_id": "snd_ngtech",
"body": "Bonjour depuis NG Messaging"
}'Envoyer un template WhatsApp
curl https://api.ng-messaging.com/v1/messages \
-H "X-NG-Api-Key: ngmsg_live_xxx" \
-H "Idempotency-Key: order-1042-confirmation" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp",
"to": "+221771234567",
"sender_id": "snd_ngtech",
"type": "template",
"template": { "name": "order_confirmed", "language": "fr", "variables": ["Aïda", "ORD-1042"] }
}'Programmer un SMS
Le SMS est débité tout de suite et reste au statut scheduled jusqu'à l'heure prévue. Tant qu'il n'est pas parti, vous pouvez l'annuler avec POST /v1/messages/:id/cancel (permission sms:send) : il passe à cancelled et son débit est remboursé. Si le message est déjà transmis ou en cours d'envoi, la réponse est 409.
curl https://api.ng-messaging.com/v1/messages \
-H "X-NG-Api-Key: ngmsg_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"to": "+221771234567",
"body": "Rappel : votre rendez-vous est demain à 10 h.",
"scheduled_at": "2026-11-02T08:00:00Z"
}'Longueur et segments SMS
Un SMS en alphabet GSM-7 tient dans un segment jusqu'à 160 caractères, puis 153 caractères par segment. Un seul caractère hors GSM-7 (emoji, certains accents) bascule tout le message en UCS-2 : 70 caractères pour un segment, puis 67. Le coût suit le nombre de segments. Avec la signature NG TECH, le nom de votre entreprise est ajouté en tête du message et compte dans la longueur. POST /v1/sms/estimate (permission sms:send, champs to, message, sender) renvoie le nombre de segments, l'encodage et le coût sans rien débiter ni envoyer.
Lire un message
Renvoie le message et sa chronologie (timeline), horodatée. En cas d'échec, error_code et error_message portent le code et le message publics ; action_required vaut true quand rien ne se résoudra sans une action de votre part. Un identifiant inconnu reçoit 404.
{
"id": "8f6c1d9e-3a52-4b7f-9d21-5c0e7b44a1f0",
"channel": "sms",
"status": "delivered",
"...": "(les champs de la réponse d'envoi)",
"timeline": [
{ "status": "queued", "at": "2026-10-23T10:00:00" },
{ "status": "sent", "at": "2026-10-23T10:00:02" },
{ "status": "delivered", "at": "2026-10-23T10:00:05" }
]
}Liste paginée : paramètres channel, status, page (1 par défaut) et limit (50 par défaut, 200 au maximum). La réponse contient items et total.
Expéditeurs WhatsApp
Une organisation peut avoir plusieurs numéros WhatsApp. Chacun est identifié publiquement par un sender_id (snd_…). Le numéro mutualisé de NG TECH, quand votre organisation y a droit, a toujours l'identifiant snd_ngtech.
| Champ | Type | Description |
|---|---|---|
| sender_id | string | À passer dans sender_id à l'envoi. |
| type | string | « own » (votre numéro) ou « ngtech » (numéro NG TECH). |
| phone_number | string? | Numéro affiché ; null pour le numéro NG TECH. |
| display_name | string? | Nom d'affichage. |
| status | string | Seuls les numéros actifs sont listés. |
| is_default | boolean | Expéditeur par défaut de l'organisation. |
{
"senders": [
{ "sender_id": "snd_8c41f0a2", "type": "own", "phone_number": "+221770000000",
"display_name": "Ma Boutique", "status": "active", "is_default": true },
{ "sender_id": "snd_ngtech", "type": "ngtech", "phone_number": null,
"display_name": "NG TECH", "status": "active", "is_default": false }
]
}Règle de sélection
En WhatsApp, l'expéditeur est choisi dans cet ordre :
- Le
sender_idde la requête, s'il appartient à votre organisation et est actif (ousnd_ngtechsi l'organisation y a droit). Sinon404 sender_not_foundou409 sender_unavailable. - Sinon l'expéditeur par défaut de l'organisation, s'il est défini. Défini mais inactif :
409 sender_unavailable, sans repli. - Sinon l'unique numéro actif de l'organisation.
- Sinon, plusieurs numéros actifs :
400 sender_required. Précisezsender_idou définissez un expéditeur par défaut. - Sinon, aucun numéro à vous n'est actif : le numéro NG TECH est utilisé si votre organisation y a droit.
Jamais de bascule implicite vers le numéro NG TECH
Si l'un de vos numéros existe mais est déconnecté ou suspendu, l'envoi est refusé en 409 sender_unavailable : il ne part pas depuis le numéro NG TECH à votre insu. Si l'accès de votre organisation au numéro NG TECH est fermé, vous recevez un code ngtech_* en 403 (voir Erreurs).
Le message renvoyé indique l'expéditeur réellement utilisé dans sender_id. Sur le numéro NG TECH, le destinataire voit NG TECH comme expéditeur : le texte libre est donc précédé du nom de votre entreprise. Pour le SMS, la signature se choisit avec le champ sender.
Fenêtre de 24 h
WhatsApp n'autorise un texte libre que dans les 24 heures qui suivent le dernier message reçu du destinataire sur ce numéro. Chaque message entrant rouvre une fenêtre de 24 heures ; vous êtes prévenu par l'événement message.received.
| Situation | Type | Ce que vous pouvez envoyer |
|---|---|---|
| Le destinataire a écrit il y a moins de 24 h | fenêtre ouverte | Texte libre ou template. |
| Aucun message reçu, ou plus de 24 h | fenêtre fermée | Un template approuvé uniquement. Un texte libre est refusé avant tout débit : 400 service_window_closed. |
Sur le numéro mutualisé NG TECH, un message reçu est attribué à l'organisation qui a envoyé le dernier message sortant à ce destinataire depuis ce numéro, dans les 30 derniers jours. La fenêtre n'est ouverte que pour cette organisation.
Statuts d'un message
| Statut | Type | Signification |
|---|---|---|
| queued | statut | Accepté et débité, en attente d'envoi. |
| processing | statut | En cours de transmission à WhatsApp ou à l'opérateur. |
| sent | statut | Pris en charge par WhatsApp ou l'opérateur. Ce n'est pas une preuve de réception. |
| delivered | statut | Remis sur le téléphone du destinataire. |
| read | statut | Lu (WhatsApp, si le destinataire a activé les accusés de lecture). |
| failed | statut | Échec définitif ; error_code donne la cause. Le message est remboursé. |
| unknown | ajout | L'issue de l'envoi est incertaine (code outcome_unknown) : le message a peut-être été remis. Il n'est ni renvoyé ni remboursé, et aucun webhook n'est émis à ce moment. |
| scheduled | ajout | SMS programmé, débité, qui attend son heure d'envoi. |
| cancelled | ajout | SMS programmé annulé avant son envoi, remboursé. |
Règles
- La progression normale est
queued,processing,sent,delivered,read. Un accusé tardif n'écrase jamais un statut plus avancé : unsentreçu aprèsdeliveredest ignoré. - « Livré » l'emporte : un échec signalé après
deliveredoureadest ignoré et ne donne lieu à aucun remboursement. - Un
failedest remboursé, une seule fois. Si un accusédeliveredarrive ensuite, le statut redevientdeliveredet l'erreur disparaît, sans nouveau débit. - Un message
unknownn'est pas remboursé tant que son issue n'est pas connue : un accuséfailedultérieur le rembourse, un accusédeliveredoureadle laisse facturé. Un message que le réseau n'a pas pu remettre dans le délai passe àfailedavec le codemessage_expired(remboursé). - Les statuts
scheduledetcancelledne sont modifiés par aucun accusé du réseau.
Le SMS ne remonte pas read. Pour suivre un message, abonnez-vous aux webhooks message.sent, message.delivered, message.read et message.failed, ou interrogez GET /v1/messages/:id.
Templates WhatsApp
WhatsApp exige un template approuvé par Meta pour écrire en premier à un contact ou hors de la fenêtre de 24 h. Créez-le ici ou depuis Messagerie → Templates, attendez la décision de Meta (le délai est fixé par Meta ; vous êtes prévenu par les événements template.approved et template.rejected), puis envoyez-le avec POST /v1/messages. Tous les endpoints ci-dessous exigent la permission whatsapp:send.
| Champ | Type | Description |
|---|---|---|
| name | string | Minuscules, chiffres et _ ; commence par une lettre (100 caractères au plus). |
| language | string? | Code langue, « fr » par défaut. |
| category | string | UTILITY, MARKETING ou AUTHENTICATION. Elle détermine le tarif. |
| body | string | Texte de 1 024 caractères au plus. Variables {{1}}, {{2}}… sans trou, sans variable en première ni en dernière position. |
| example | string? | Message complet d'exemple, requis dès que le corps contient des variables. |
| header / footer | string? | En-tête et pied de message. |
| buttons | object[]? | Boutons du template. |
| code_expiration_minutes | number? | AUTHENTICATION : de 1 à 90 minutes. Le corps contient alors exactement une variable, {{1}}, le code. |
| sender_id | string? | Numéro (snd_…) auprès duquel créer le template. Sans lui, la règle de sélection de l'expéditeur s'applique. |
curl https://api.ng-messaging.com/v1/templates \
-H "X-NG-Api-Key: ngmsg_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "order_confirmed",
"language": "fr",
"category": "UTILITY",
"body": "Bonjour {{1}}, votre commande {{2}} est confirmée. Merci de votre confiance.",
"example": "Bonjour Aïda, votre commande ORD-1042 est confirmée. Merci de votre confiance."
}'Réponse (le champ status vaut ensuite approved, rejected, paused, disabled ou deleted) :
{
"id": "…",
"name": "order_confirmed",
"language": "fr",
"category": "UTILITY",
"status": "pending",
"body": "Bonjour {{1}}, votre commande {{2}} est confirmée. Merci de votre confiance.",
"rejection_reason": null
}| Endpoint | Réponse | Description |
|---|---|---|
| GET /v1/templates | 200 | Vos templates approuvés, les seuls utilisables pour l'envoi. Paramètre optionnel sender_id. |
| GET /v1/templates/:name | 200 / 404 | Un template quel que soit son statut, avec rejection_reason en cas de refus. name peut aussi être l'id. |
| DELETE /v1/templates/:name | 204 / 404 | Supprime le template. |
| POST /v1/templates/sync | 200 | Relit les statuts auprès de Meta ; renvoie { "status": "ok" }. |
| POST /v1/templates/send | 202 | Variante d'envoi d'un template (to, name, language, variables, sender_id, idempotency_key). POST /v1/messages reste le point d'entrée recommandé. |
Templates du numéro NG TECH
Sur le numéro NG TECH (sender_id snd_ngtech), les templates forment un catalogue commun, activé pour votre organisation par NG Messaging. Vous ne pouvez ni les créer, ni les modifier, ni les supprimer : POST /v1/templates, DELETE et sync reçoivent 403. GET /v1/templates?sender_id=snd_ngtech liste ceux qui vous sont ouverts. Envoyer un template qui ne l'est pas donne 403 ngtech_template_not_allowed, et un pays non autorisé pour ce template 400 ngtech_country_not_allowed. Pour créer vos propres templates, connectez votre propre numéro WhatsApp.
Un template appartient à un numéro : approuvé sur un numéro, il n'existe pas sur un autre. Avec une clé de test, POST /v1/messages refuse les templates (400) : utilisez une clé live.
OTP
NG Messaging génère le code, l'envoie et le vérifie : votre application ne manipule qu'un identifiant de demande, request_id. Le code n'est jamais renvoyé par l'API et n'est conservé que sous forme d'empreinte. Les deux endpoints exigent la permission otp:send.
| Champ | Type | Description |
|---|---|---|
| to | string | Numéro au format international. |
| channel | string? | « whatsapp » (défaut), « sms » ou « auto ». |
| template / language | string? | WhatsApp : template d'authentification (sinon celui des réglages OTP de l'organisation) et sa langue, « fr » par défaut. |
| code_length | number? | De 4 à 10 chiffres (6 par défaut, ou le réglage de l'organisation). |
| expires_in_minutes | number? | Durée de validité, de 1 à 60 minutes (10 par défaut). |
| max_attempts | number? | Tentatives de vérification, de 1 à 10 (5 par défaut). |
| request_id | string? | Votre identifiant de demande ; généré s'il est absent. Le rejouer ne renvoie ni ne débite deux fois. |
| idempotency_key | string? | Aussi acceptée dans l'en-tête Idempotency-Key ; sans request_id, elle en tient lieu. |
| code | string? | Pour les intégrations qui génèrent leur propre code : il est livré tel quel et reste vérifiable. Sinon NG Messaging en génère un. |
| client_ip | string? | Adresse IP (IPv4 ou IPv6) de l'utilisateur final. Elle active la limite par adresse IP, sans quoi seule l'IP de votre serveur serait vue. |
Une valeur hors bornes est refusée en 400 avec le nom du champ et l'intervalle : elle n'est jamais corrigée en silence. Le canal auto choisit WhatsApp quand un numéro est utilisable et qu'un template d'authentification approuvé existe, et le SMS sinon. Avec une clé de test, le canal auto choisit le SMS. Par SMS, le message est composé par NG Messaging.
curl https://api.ng-messaging.com/v1/otp \
-H "X-NG-Api-Key: ngmsg_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "to": "+221771234567", "channel": "auto" }'Réponse (avec une clé de test, cost_fcfa vaut 0) :
{
"request_id": "otp_3f8a1c92b7d4e6f0",
"message_id": "9594319d-55ab-4a38-b921-34cda240e701",
"status": "queued",
"expires_at": "2026-10-23T10:10:00",
"cost_fcfa": 0
}curl https://api.ng-messaging.com/v1/otp/verify \
-H "X-NG-Api-Key: ngmsg_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "request_id": "otp_3f8a1c92b7d4e6f0", "code": "438210" }'Réponse 200 :
{
"id": "…",
"request_id": "otp_3f8a1c92b7d4e6f0",
"phone_number": "+221771234567",
"status": "verified",
"attempts": 1,
"max_attempts": 5,
"expires_at": "2026-10-23T10:10:00",
"verified_at": "2026-10-23T10:01:12",
"message_id": "9594319d-55ab-4a38-b921-34cda240e701",
"created_at": "2026-10-23T10:00:00"
}| HTTP | Situation | Détail |
|---|---|---|
| 200 | valide | Code correct ; l'événement otp.verified est émis. |
| 400 | incorrect | Code incorrect ; le message indique le nombre de tentatives restantes. Chaque échec consomme une tentative. |
| 404 | inconnu | Aucune demande pour ce request_id. |
| 409 | déjà vérifié | Ce code a déjà été vérifié. |
| 410 | expiré | Le code a expiré : demandez-en un nouveau. |
| 429 | trop de tentatives | Le maximum de tentatives est atteint : la demande est close, il faut en envoyer une nouvelle. |
Antifraude
Avant tout envoi et tout débit, NG Messaging applique des limites contre le détournement des codes. Un refus est une réponse 429, avec un en-tête Retry-After (en secondes) quand une attente a un sens, et un corps contenant error, message et retry_after. Aucun message n'est créé et rien n'est débité.
| error | HTTP | Cause |
|---|---|---|
| number_cooldown | 429 | Un code vient d'être envoyé à ce numéro : délai minimum entre deux codes. |
| number_hourly | 429 | Trop de codes envoyés à ce numéro sur l'heure par votre organisation. |
| number_daily_global | 429 | Ce numéro a atteint sa limite de codes sur 24 h, toutes organisations confondues. |
| ip_hourly | 429 | Trop de codes demandés depuis la même adresse IP d'utilisateur final (si client_ip est fourni). |
| account_hourly / account_daily | 429 | Limite horaire ou quotidienne de codes de votre organisation. |
| international_hourly | 429 | Limite horaire des codes vers l'international. |
| spend_daily | 429 | Plafond de dépense OTP du jour atteint (clés live). |
| platform_international_spend_daily | 429 | Plafond de sécurité de la plateforme vers l'international atteint. |
| workspace_otp_suspended | 429 | Envoi d'OTP suspendu temporairement après une activité anormale (beaucoup de codes envoyés, très peu vérifiés). |
| country_blocked | 400 | Les codes vers cette destination ne sont pas autorisés. Pas de Retry-After. |
Les seuils exacts sont fixés par NG Messaging et peuvent être ajustés pour votre organisation sur demande au support.
Si l'envoi échoue sur un canal, NG Messaging ne retente pas sur l'autre. Si la réception du code est critique pour votre parcours, proposez un bouton « recevoir par SMS ».
Webhooks
Configurez une URL HTTPS depuis Développeurs → Webhooks et choisissez les événements auxquels vous vous abonnez : un événement non coché n'est jamais envoyé. Le secret de l'endpoint (préfixe ngwhsec_) sert à vérifier la signature ; il se renouvelle depuis la même page. L'URL ne doit pas pointer vers un réseau interne et les redirections ne sont pas suivies.
Événements
| Événement | Type | Quand |
|---|---|---|
| message.sent | event | Le message est pris en charge par WhatsApp ou l'opérateur. |
| message.delivered | event | Le message est remis au destinataire. |
| message.read | event | Le destinataire l'a lu (WhatsApp uniquement). |
| message.failed | event | Échec, avec error_code et error_message ; le message est remboursé. |
| message.received | event | Un contact vous a écrit sur WhatsApp. Le payload porte l'expéditeur du message (from), son type et son contenu, ainsi que sender_id, le numéro qui l'a reçu. |
| template.approved / template.rejected | event | Décision de Meta sur un template : name, language_code, reason. |
| otp.sent | event | Un code a été envoyé : request_id, message_id, to, channel, expires_at. |
| otp.verified | event | Un code a été vérifié : request_id, to, verified_at. |
| campaign.completed | event | Une campagne a fini d'envoyer : campaign_id, recipients, sent, failed, skipped, cost_fcfa. |
| wallet.low_balance | event | Votre solde passe sous le seuil d'alerte : balance_fcfa, threshold_fcfa. |
Forme de la requête
Chaque livraison est un POST JSON dont data dépend de l'événement. Pour les événements message.*, data.message_id est l'identifiant renvoyé par l'envoi, et error_code / error_message ne sont renseignés qu'en cas d'échec (avec en plus error et errors).
{
"id": "evt_6b79e8b422f9432e",
"event_type": "message.delivered",
"source": "ng_messaging",
"version": "1.0",
"company_id": "0e5ad9db-a30e-45e7-a69d-6daa476961a0",
"occurred_at": "2026-10-23T10:00:05.103787Z",
"received_at": "2026-10-23T10:00:05.103787Z",
"correlation_id": "corr_61d340fcddac42ca",
"data": {
"message_id": "9594319d-55ab-4a38-b921-34cda240e701",
"channel": "whatsapp",
"to": "221771234567",
"status": "delivered",
"correlation_id": "7c9eb28e-8055-40a0-bd01-c9615cfa1d85",
"error_code": null,
"error_message": null,
"timestamp": "2026-10-23T10:00:05.103787Z"
}
}En-têtes et signature
| En-tête | Type | Contenu |
|---|---|---|
| X-NG-Signature | string | « sha256= » suivi du HMAC-SHA256 en hexadécimal. |
| X-NG-Timestamp | string | Horodatage Unix, en secondes. |
| X-NG-Event-Id | string | Identifiant de l'événement (evt_…), identique à id dans le corps. |
| X-NG-Delivery-Attempt | number | Numéro de la tentative de livraison, à partir de 1. |
La signature est le HMAC-SHA256, avec le secret de l'endpoint comme clé, de {timestamp}.{corps brut}. Calculez-la sur les octets reçus, avant tout décodage JSON, et refusez les horodatages trop anciens.
import crypto from "node:crypto"
import express from "express"
const app = express()
// Le corps BRUT est indispensable : ne le passez pas par express.json() avant la vérification.
app.post("/webhooks/ng-messaging", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.header("X-NG-Timestamp") ?? ""
const signature = req.header("X-NG-Signature") ?? ""
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400)
const attendu =
"sha256=" +
crypto
.createHmac("sha256", process.env.NG_WEBHOOK_SECRET)
.update(Buffer.concat([Buffer.from(timestamp + "."), req.body]))
.digest("hex")
const a = Buffer.from(attendu)
const b = Buffer.from(signature)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)
res.sendStatus(200) // accusez réception d'abord, traitez ensuite
const event = JSON.parse(req.body.toString("utf8"))
// dédoublonnez sur req.header("X-NG-Event-Id") puis traitez event.event_type
})import hashlib
import hmac
import time
def verifier(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
# Refuser un horodatage trop ancien : protège du rejeu d'un appel intercepté.
if abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
attendu = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(attendu, signature)Réponses, reprises et rejeu
Répondez par un code 2xx en moins de 8 secondes puis traitez l'événement. Toute autre réponse, ou une erreur réseau, est un échec : NG Messaging retente après 30 secondes, 2 minutes, 10 minutes, 1 heure puis 6 heures, soit 6 tentatives au total. Ensuite la livraison passe à l'état dead et reste consultable. Un même événement peut arriver plusieurs fois : dédoublonnez sur X-NG-Event-Id. Un échec de webhook n'affecte jamais l'envoi du message qui l'a déclenché.
Journal des livraisons, les plus récentes d'abord. Paramètres : status (pending, delivered ou dead) et limit (50 par défaut, 200 au maximum). Chaque entrée porte id, event_id, event_type, status, attempts, last_http_status, last_error, next_attempt_at et delivered_at.
Rejoue une livraison avec un compteur de tentatives remis à zéro. Une livraison inconnue reçoit 404, et une livraison déjà réussie 409.
Idempotence
Une clé d'idempotence garantit qu'une même opération métier crée un seul message et un seul débit, même si votre code réessaie après un délai d'attente. Elle s'applique à tous les envois : messages, templates et OTP. Transmettez-la dans l'en-tête Idempotency-Key ou dans le champ idempotency_key du corps.
- Rejouer la même clé ne renvoie pas d'erreur : l'API retourne le message déjà créé, sans rien renvoyer ni débiter de nouveau.
- En-tête et corps fournis ensemble avec deux valeurs différentes :
400 idempotency_key_mismatch. - Un message refusé faute de solde (402) ne réserve pas sa clé : après avoir rechargé, vous pouvez réessayer avec la même.
- Pour un OTP, la clé détermine le
request_idquand vous n'en fournissez pas : un rejeu ne renvoie pas de nouveau code.
Choisissez un identifiant stable et propre à l'opération (un numéro de commande), pas un horodatage.
Codes d'erreur
Toute erreur de l'API a le même corps JSON : detail (chaîne, objet ou liste, selon l'erreur), error (code stable sur lequel brancher votre code), message (texte lisible, en français) et request_id. Ce dernier est aussi renvoyé dans l'en-tête X-Request-Id : communiquez-le au support.
{
"detail": "Hors fenêtre de 24 h : ce destinataire ne vous a pas écrit récemment. Utilisez un template approuvé.",
"error": "service_window_closed",
"message": "Hors fenêtre de 24 h : ce destinataire ne vous a pas écrit récemment. Utilisez un template approuvé.",
"request_id": "3f9a0c7e41b84d2e9a1c5f60"
}Solde insuffisant (402)
Un solde insuffisant est une réponse 402 avec le code insufficient_balance. Selon le circuit, detail est un objet qui donne le montant requis et le solde disponible, ou une chaîne.
{
"detail": { "error": "insufficient_balance", "required": 15, "available": 4 },
"error": "insufficient_balance",
"message": "Solde insuffisant.",
"request_id": "3f9a0c7e41b84d2e9a1c5f60"
}Codes par statut HTTP
Quand une erreur n'a pas de code public propre, error reprend le code de son statut HTTP :
| HTTP | error | Signification |
|---|---|---|
| 400 | bad_request | Requête invalide : le message nomme la cause (pays fermé, signature inconnue, valeur hors bornes, média non disponible…). |
| 401 | unauthorized | Clé API absente, de format invalide, invalide ou révoquée. |
| 402 | insufficient_balance | Solde insuffisant. Le message n'est pas envoyé. |
| 403 | forbidden | Permission manquante sur la clé, compte suspendu, adresse email non confirmée, ou accès au numéro NG TECH fermé (codes ngtech_*). |
| 404 | not_found | Ressource introuvable : message, template, livraison ou expéditeur. |
| 409 | conflict | Conflit avec l'état de la ressource : aucun canal WhatsApp actif, expéditeur indisponible, SMS déjà parti, OTP déjà vérifié. |
| 410 | gone | Code OTP expiré. |
| 422 | validation_error | Corps mal formé ou champ manquant : le message nomme le champ en cause. |
| 429 | rate_limited | Limite atteinte : clé de test, numéro NG TECH, ou antifraude OTP. Respectez Retry-After quand il est présent. |
| 500 | internal_error | Erreur interne. Communiquez le request_id au support. |
| 502 / 503 | messaging_temporarily_unavailable | Service d'envoi momentanément indisponible : réessayez avec la même clé d'idempotence. |
Codes publics
Ces codes décrivent un refus de votre requête (colonne HTTP renseignée) ou l'échec d'un message après la réponse 202. Dans ce second cas, vous les lisez dans error_code et error_message sur GET /v1/messages/:id et dans l'événement message.failed ; le statut HTTP ne s'applique pas. Le message est toujours celui du tableau, jamais un texte brut d'un tiers. Un code absent du tableau doit être traité comme messaging_temporarily_unavailable.
| Code | HTTP | Message |
|---|---|---|
| invalid_recipient | - | Le numéro du destinataire est invalide. |
| recipient_unreachable | - | Le destinataire est injoignable avec ce canal. |
| country_not_supported | - | L'envoi vers ce pays n'est pas disponible. |
| sender_not_approved | - | L'expéditeur (signature) n'est pas approuvé pour cet envoi. |
| template_not_found | - | Le template demandé est introuvable. |
| template_rejected | - | Le template a été refusé. |
| template_invalid | - | Le template ou ses variables sont invalides. |
| content_rejected | - | Le contenu du message a été refusé. |
| insufficient_balance | 402 | Solde insuffisant pour envoyer ce message. |
| rate_limited | - | Cadence d'envoi trop élevée. Réessayez dans un instant. |
| outcome_unknown | - | Statut en cours de vérification : le message a peut-être été remis. |
| message_expired | - | Le message n'a pas pu être remis dans le délai imparti. |
| messaging_temporarily_unavailable | - | Service d'envoi momentanément indisponible. Réessayez plus tard. |
| internal_error | - | Erreur interne. Le message n'a pas pu être envoyé. |
| sender_required | 400 | Plusieurs numéros WhatsApp sont actifs : précisez sender_id ou définissez un expéditeur par défaut. |
| sender_not_found | 404 | Expéditeur introuvable : ce sender_id n'existe pas pour votre organisation. |
| sender_unavailable | 409 | Cet expéditeur n'est pas disponible (numéro déconnecté ou suspendu). Reconnectez-le ou choisissez un autre expéditeur. |
| service_window_closed | 400 | Hors fenêtre de 24 h : ce destinataire ne vous a pas écrit récemment. Utilisez un template approuvé. |
| ngtech_not_authorized | 403 | Votre organisation n'est pas autorisée à utiliser le numéro NG TECH. |
| ngtech_disabled | 403 | Le numéro NG TECH n'est pas disponible pour le moment. |
| ngtech_suspended | 403 | L'accès de votre organisation au numéro NG TECH est suspendu. Contactez le support. |
| ngtech_template_not_allowed | 403 | Ce template n'est pas disponible pour votre organisation. |
| ngtech_country_not_allowed | 400 | Ce template n'est pas autorisé vers ce pays. |
| ngtech_rate_limited | 429 | Trop d'envois par minute sur le numéro NG TECH, réessayez dans un instant. |
Codes Meta normalisés
Ces codes en majuscules concernent votre compte WhatsApp Business. action_required vaut true sur le message quand aucun réessai ne servira tant que vous n'avez rien changé.
| Code | HTTP | Message | Action requise |
|---|---|---|---|
| TEMPLATE_OUTSIDE_WINDOW | - | Texte libre impossible hors de la fenêtre de 24 h : utilisez un template approuvé. | non |
| RECIPIENT_UNREACHABLE | - | Numéro invalide, ou sans compte WhatsApp. | non |
| PAYMENT_METHOD_ISSUE | - | Le moyen de paiement du compte WhatsApp Business est en défaut. | oui |
| BUSINESS_VERIFICATION_REQUIRED | - | L'entreprise doit être vérifiée par Meta. | oui |
| ACCOUNT_RESTRICTED | - | Le compte WhatsApp Business est restreint. | oui |
| RATE_LIMIT_HIT | - | Cadence d'envoi trop élevée. Ralentissez puis réessayez. | non |
| INVALID_PARAMETER | - | Un paramètre du message a été refusé (variables ou format du template). | non |
| INVALID_ACCESS_TOKEN | - | La connexion WhatsApp doit être renouvelée : reconnectez le numéro. | oui |
| META_API_ERROR | - | Le message a été refusé par WhatsApp. | non |
Autres codes
| error | HTTP | Cas |
|---|---|---|
| idempotency_key_mismatch | 400 | L'en-tête Idempotency-Key et le champ idempotency_key du corps ont des valeurs différentes. |
| validation_error | 422 | Corps mal formé ou champ manquant. |
| codes antifraude OTP | 429 / 400 | Voir la section OTP. |
Réessayer proprement
Seuls 429, 502 et 503 justifient un nouvel essai, avec un délai croissant et la même clé d'idempotence pour ne pas créer de doublon. Un 400, 401, 402, 403, 404 ou 422 se corrige dans la requête ou dans le compte.
Limites et quotas
Voici ce que l'API applique. Les valeurs par défaut peuvent être ajustées pour votre organisation par NG Messaging.
| Limite | Réponse | Détail |
|---|---|---|
| Clé de test | 429 | Volume quotidien limité : 20 messages par jour par défaut, SMS et WhatsApp confondus. Les messages de test ne sont jamais facturés. |
| Clé live, email non confirmé | 403 | L'adresse email du propriétaire du compte doit être confirmée pour tout envoi réel. |
| Numéro NG TECH, quota quotidien | 429 | Plafond de messages par jour et par organisation sur le numéro mutualisé (50 par défaut). Il se lève en connectant votre propre numéro WhatsApp. Un échec remboursé avant tout envoi ne consomme pas le quota. |
| Numéro NG TECH, débit | 429 ngtech_rate_limited | Nombre maximal de messages par minute et par organisation (60 par défaut). Réessayez dans un instant. |
| Template du numéro NG TECH | 429 | Certains templates du catalogue ont leur propre plafond quotidien par organisation. |
| OTP | 429 / 400 | Délai entre deux codes vers un même numéro, plafonds par numéro, par adresse IP, par organisation et vers l'international, plafond de dépense, suspension en cas d'activité anormale. Voir la section OTP. |
| SMS programmé | 400 | Entre 1 minute et 90 jours dans le futur. |
| Pays | 400 | Un pays fermé est refusé avant tout débit, avec la liste des pays ouverts pour le SMS. |
Indépendamment de NG Messaging, Meta applique ses propres limites à chaque numéro WhatsApp : palier d'envoi, note de qualité, cadence. Un envoi trop rapide donne le code RATE_LIMIT_HIT.
Tarifs
Les tarifs dépendent du canal, de la destination et de la catégorie. La grille en vigueur est publiée sur la page Tarifs ; elle est aussi lisible sans clé avec GET /v1/billing/message-pricing. Aucun prix n'est donné ici pour ne jamais diverger de la facturation.
- Montant débité. Le coût de chaque message est fixé à l'acceptation et renvoyé dans
cost_fcfa; il vaut 0 avec une clé de test. Un message en échec est remboursé. - SMS. Le prix suit le pays et le nombre de segments (voir Longueur et segments SMS). La signature utilisée compte aussi : NG TECH ou la signature approuvée de votre compte.
POST /v1/sms/estimatedonne le devis exact sans rien débiter. - WhatsApp. Le prix suit la catégorie du message :
| Catégorie | Type | Usage |
|---|---|---|
| marketing | template | Promotions, relances, annonces. |
| utility | template | Suite d'une action du client : confirmation de commande, statut de livraison. |
| authentication | template | Codes à usage unique (OTP). |
| service | texte libre | Réponse en texte libre dans la fenêtre de 24 h qui suit un message du destinataire. |
La catégorie d'un template est celle que Meta lui attribue, pas celle que vous espérez : déclarer un message promotionnel comme utility conduit Meta à le reclasser. Le tarif appliqué suit la catégorie réelle.