En Arabie saoudite et dans tout le Golfe, WhatsApp n'est pas un canal marketing. C'est le canal. C'est là qu'un client demande si la pièce est en stock, qu'une clinique confirme un rendez-vous, qu'un entrepreneur envoie un devis. Toute entreprise sérieuse ici y fonctionne déjà — généralement via un téléphone personnel qu'un employé possède et emporte chez lui à 17h.
L'écart entre cette réalité et une véritable intégration est exactement là où la plupart des projets s'enlisent. Cherchez « bot WhatsApp » en arabe et vous trouverez quarante plateformes SaaS sans code vendant des abonnements mensuels, et presque rien qui montre à un développeur comment fonctionne réellement l'API Meta. Ce tutoriel comble ce vide : le Cloud API, au niveau du code, avec un vrai agent IA à l'autre bout.
Ce que vous allez construire
Un agent WhatsApp de qualité production sur Next.js 15 avec l'App Router :
- Un endpoint webhook que Meta peut vérifier et qui rejette les requêtes falsifiées
- Un parsing typé des messages entrants, avec idempotence pour que les relances ne produisent pas de doublons
- Un client d'envoi pour le texte libre, les accusés de lecture et les modèles approuvés
- Une gestion correcte de la fenêtre de service client de 24 heures
- Un agent IA pensé d'abord pour l'arabe, propulsé par Claude, qui comprend le contexte du Golfe et sait quand s'arrêter
- Un chemin de transfert propre vers un être humain
Prérequis
- Node.js 20+ et un projet Next.js 15 utilisant l'App Router
- Un compte Meta Business avec une entreprise vérifiée. En Arabie saoudite, cela signifie votre registre de commerce (السجل التجاري) ; en Tunisie, votre patente. La vérification prend des jours, pas des minutes — lancez-la avant d'écrire la moindre ligne de code.
- Un numéro de téléphone qui n'est pas actuellement actif sur l'application WhatsApp grand public ni sur WhatsApp Business. Dès qu'un numéro passe au Cloud API, il quitte ces applications.
- Une clé API Anthropic pour la partie agent
- Une URL HTTPS publique. Meta n'appellera ni un endpoint HTTP ni une adresse localhost. Utilisez un tunnel comme
ngrok http 3000en développement.
Note sur les coûts. Meta offre un quota gratuit de conversations par mois, puis facture par message selon la catégorie et le pays du destinataire. Les tarifs saoudiens et tunisiens diffèrent. Consultez la page de tarification en vigueur pour votre marché avant de promettre un numéro à qui que ce soit — les tarifs ont changé deux fois ces deux dernières années.
Étape 1 : configurer l'application Meta
Dans la console Meta for Developers, créez une application de type Business, puis ajoutez-lui le produit WhatsApp. Vous atterrirez sur une page de démarrage rapide qui vous donne quatre valeurs. Placez-les immédiatement dans .env.local et ne les codez jamais en dur :
# .env.local
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321098765
WHATSAPP_ACCESS_TOKEN=EAAJB...
WHATSAPP_APP_SECRET=a1b2c3d4e5f6...
WHATSAPP_VERIFY_TOKEN=choisissez-vous-meme-une-longue-chaine-aleatoire
ANTHROPIC_API_KEY=sk-ant-...Deux d'entre elles méritent une explication.
WHATSAPP_VERIFY_TOKEN n'est pas émis par Meta. C'est vous qui l'inventez, et vous collez la même valeur dans le formulaire de configuration du webhook chez Meta. Il existe pour que, lorsque Meta appelle votre endpoint pour le vérifier, vous puissiez confirmer que l'appel provient bien de votre propre configuration.
WHATSAPP_APP_SECRET se trouve sous App Settings → Basic. C'est la clé HMAC avec laquelle Meta signe chaque charge utile de webhook. Sans elle, vous ne pouvez pas distinguer un webhook authentique de n'importe qui sur Internet ayant trouvé votre URL.
Le jeton d'accès temporaire de la page de démarrage expire en 24 heures. Pour tout ce qui dépasse un premier test, créez un System User dans Business Settings, attribuez-lui votre compte WhatsApp Business avec contrôle total, et générez un jeton permanent. Faites-le tôt — découvrir que votre bot est mort dans la nuit à cause d'un jeton de développement, c'est un mauvais mardi.
Ajoutez un petit module de configuration pour que le reste du code échoue bruyamment en cas de variable manquante, au lieu d'envoyer des requêtes vers undefined :
// lib/whatsapp/config.ts
function required(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var: ${name}`)
return value
}
export const WHATSAPP = {
graphVersion: 'v23.0',
phoneNumberId: required('WHATSAPP_PHONE_NUMBER_ID'),
accessToken: required('WHATSAPP_ACCESS_TOKEN'),
appSecret: required('WHATSAPP_APP_SECRET'),
verifyToken: required('WHATSAPP_VERIFY_TOKEN'),
} as const
export const GRAPH_BASE = `https://graph.facebook.com/${WHATSAPP.graphVersion}`Étape 2 : la poignée de main de vérification du webhook
Lorsque vous enregistrez une URL de webhook dans la console Meta, Meta envoie immédiatement une requête GET avec trois paramètres : hub.mode, hub.verify_token et hub.challenge. Vous devez renvoyer le challenge en texte brut — et uniquement si le jeton correspond.
// app/api/whatsapp/webhook/route.ts
import { WHATSAPP } from '@/lib/whatsapp/config'
export async function GET(request: Request) {
const params = new URL(request.url).searchParams
const mode = params.get('hub.mode')
const token = params.get('hub.verify_token')
const challenge = params.get('hub.challenge')
if (mode === 'subscribe' && token === WHATSAPP.verifyToken && challenge) {
return new Response(challenge, {
status: 200,
headers: { 'content-type': 'text/plain' },
})
}
return new Response('Forbidden', { status: 403 })
}Trois erreurs cassent cette poignée de main, et toutes trois produisent le même message inutile dans la console Meta :
- Renvoyer du JSON. Meta compare le corps brut de la réponse à la chaîne du challenge.
Response.json(challenge)l'entoure de guillemets et échoue. - Une barre oblique finale qui diffère. L'URL dans la console doit correspondre exactement à votre route.
- Vérifier avant de déployer. L'endpoint doit être en ligne et publiquement accessible au moment où vous appuyez sur Enregistrer, pas après.
Une fois la vérification réussie, abonnez-vous au champ messages dans la configuration du webhook. Rien n'arrive tant que vous ne l'avez pas fait.
Étape 3 : valider la signature
C'est l'étape que la plupart des tutoriels sautent, et c'est celle qui compte. Votre URL de webhook est un endpoint HTTPS public. Quiconque la découvre peut poster un faux « message client » et faire répondre votre agent IA à un inconnu — ou pire, déclencher toute la logique métier qui se trouve derrière.
Meta signe chaque POST avec un HMAC-SHA256 du corps brut de la requête, avec votre App Secret comme clé, dans l'en-tête X-Hub-Signature-256. Vous devez le recalculer et le comparer.
// lib/whatsapp/verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { WHATSAPP } from './config'
export function isValidSignature(rawBody: string, header: string | null): boolean {
if (!header?.startsWith('sha256=')) return false
const expected = createHmac('sha256', WHATSAPP.appSecret)
.update(rawBody, 'utf8')
.digest('hex')
const received = header.slice('sha256='.length)
// Vérification de longueur d'abord : timingSafeEqual lève une erreur
// si les tampons n'ont pas la même taille.
if (received.length !== expected.length) return false
return timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))
}Deux détails portent tout le poids.
Vous devez hacher le corps brut, octet par octet. Si vous appelez await request.json() puis re-sérialisez l'objet, l'ordre des clés et les espaces différeront de ce que Meta a signé, et toutes les signatures échoueront. Lisez le corps en texte une seule fois, vérifiez-le, puis parsez la chaîne que vous avez déjà.
Utilisez timingSafeEqual, pas ===. Une comparaison de chaînes classique s'arrête dès qu'elle trouve un caractère différent, et cet écart de temps suffit à un attaquant pour reconstituer une signature valide octet par octet. Le garde-fou de longueur qui la précède est nécessaire parce que timingSafeEqual lève une exception au lieu de renvoyer false lorsque les tampons diffèrent en taille.
Étape 4 : parser les messages entrants
La charge utile du webhook est profondément imbriquée et transporte plus que des messages clients. Voici un vrai message texte, allégé :
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "966500000000",
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [{
"profile": { "name": "Nom du client" },
"wa_id": "966555555555"
}],
"messages": [{
"from": "966555555555",
"id": "wamid.HBgL...",
"timestamp": "1786000000",
"type": "text",
"text": { "body": "Avez-vous cet article en stock ?" }
}]
}
}]
}]
}La distinction critique : un objet value contenant un tableau messages est un message client entrant. Un value contenant un tableau statuses est un accusé de livraison pour quelque chose que vous avez envoyé — envoyé, distribué, lu ou échoué. Si vous ne séparez pas les deux, votre agent tentera joyeusement de répondre à ses propres accusés de lecture.
// lib/whatsapp/parse.ts
export type InboundMessage = {
wamid: string
from: string
profileName: string
text: string
timestamp: number
}
type WebhookPayload = {
entry?: Array<{
changes?: Array<{
value?: {
contacts?: Array<{ profile?: { name?: string }; wa_id?: string }>
messages?: Array<{
from: string
id: string
timestamp: string
type: string
text?: { body: string }
}>
}
}>
}>
}
export function extractMessages(payload: WebhookPayload): InboundMessage[] {
const out: InboundMessage[] = []
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value
// L'absence de `messages` signifie un callback de statut, pas un message client.
if (!value?.messages) continue
const profileName = value.contacts?.[0]?.profile?.name ?? ''
for (const message of value.messages) {
if (message.type !== 'text' || !message.text) continue
out.push({
wamid: message.id,
from: message.from,
profileName,
text: message.text.body,
timestamp: Number(message.timestamp) * 1000,
})
}
}
}
return out
}Nous filtrons ici sur type === 'text' par souci de clarté. En production, vous verrez aussi image, audio, document, location, button et interactive — l'audio en particulier mérite d'être traité sur les marchés arabophones, où les notes vocales sont souvent le mode de communication par défaut des clients. Traitez-les de la même manière : branchez sur type et extrayez la charge utile correspondante.
Idempotence
Meta relance un webhook si vous ne renvoyez pas rapidement un 2xx. Ces relances portent le même wamid. Sans déduplication, une question client devient trois réponses IA identiques et trois événements de facturation.
Chaque identifiant de message est unique au monde : utilisez-le comme clé de déduplication.
// lib/whatsapp/seen.ts
const seen = new Map<string, number>()
const TTL_MS = 10 * 60 * 1000
export function alreadyHandled(wamid: string): boolean {
const now = Date.now()
// Nettoyage opportuniste pour que la map ne grossisse pas sans limite.
for (const [key, at] of seen) {
if (now - at > TTL_MS) seen.delete(key)
}
if (seen.has(wamid)) return true
seen.set(wamid, now)
return false
}Une map en mémoire convient pour une instance unique. Dès que vous en exécutez plusieurs — n'importe quelle plateforme serverless, tout déploiement scalé horizontalement — déplacez ceci vers Redis ou une base de données avec une contrainte d'unicité sur wamid. Deux instances possédant chacune sa propre map ne dédupliquent rien.
Étape 5 : le client d'envoi
L'envoi est un POST vers /PHONE_NUMBER_ID/messages avec un jeton bearer. La forme du corps dépend du type de message.
// lib/whatsapp/send.ts
import { GRAPH_BASE, WHATSAPP } from './config'
async function call(body: Record<string, unknown>) {
const response = await fetch(`${GRAPH_BASE}/${WHATSAPP.phoneNumberId}/messages`, {
method: 'POST',
headers: {
authorization: `Bearer ${WHATSAPP.accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const detail = await response.text()
throw new Error(`WhatsApp send failed (${response.status}): ${detail}`)
}
return response.json() as Promise<{ messages: Array<{ id: string }> }>
}
export function sendText(to: string, body: string, previewUrl = false) {
return call({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { preview_url: previewUrl, body },
})
}
export function markAsRead(wamid: string) {
return call({
messaging_product: 'whatsapp',
status: 'read',
message_id: wamid,
})
}markAsRead est une petite chose qui vaut la peine. Les coches bleues qui apparaissent en une seconde indiquent au client qu'un vrai système a reçu son message, ce qui vous achète les quelques secondes dont le modèle a besoin pour réfléchir.
Notez le champ to : un numéro international sans +, sans espaces, sans tirets. 966555555555, et non +966 55 555 5555. Le champ from des messages entrants est déjà dans ce format, donc le renvoyer tel quel est sûr.
Étape 6 : la fenêtre de 24 heures et les modèles
C'est la règle qui façonne tout produit WhatsApp, et la mal comprendre est la cause la plus fréquente d'un lancement recalé à la revue.
Vous ne pouvez pas écrire à un client quand bon vous semble. Quand un utilisateur vous envoie un message, une fenêtre de service client de 24 heures s'ouvre. Dans cette fenêtre, vous pouvez envoyer des messages libres — n'importe quel texte, n'importe quel contenu. Chaque nouveau message de l'utilisateur remet le compteur à 24 heures. Une fois fermée, les messages libres sont rejetés et vous ne pouvez plus envoyer qu'un modèle pré-approuvé.
Pour un agent piloté par l'entrant, c'est presque invisible : le client vous a écrit, donc la fenêtre est ouverte. Cela devient visible dès que l'entreprise veut envoyer un rappel de rendez-vous, une mise à jour de commande ou une relance de devis.
Les modèles se soumettent via le WhatsApp Manager et sont examinés par Meta, en général en quelques heures. Chacun a un nom, un code de langue et des variables numérotées.
Enregistrez la version arabe avec le code de langue ar — avec un corps écrit naturellement, pas traduit automatiquement :
مرحباً {{1}}، طلبك رقم {{2}} جاهز للاستلام من فرعنا. شكراً لثقتك بنا.
Envoyez-le avec les paramètres dans l'ordre :
// lib/whatsapp/send.ts (suite)
type TemplateParam = { type: 'text'; text: string }
export function sendTemplate(
to: string,
name: string,
languageCode: 'ar' | 'en' | 'fr',
params: string[] = [],
) {
const components =
params.length > 0
? [{
type: 'body',
parameters: params.map<TemplateParam>((text) => ({ type: 'text', text })),
}]
: undefined
return call({
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name,
language: { code: languageCode },
...(components ? { components } : {}),
},
})
}Trois points qui coûtent régulièrement une journée à chaque équipe :
- Les paramètres sont positionnels.
params[0]remplit le premier emplacement,params[1]le second. Il n'y a pas de variables nommées. Un écart entre le nombre envoyé et celui du modèle approuvé renvoie une erreur132000. - Le code de langue doit correspondre exactement au modèle approuvé. Un modèle approuvé en
arne peut pas être envoyé enar_SA. Pour l'API, ce sont deux modèles différents. - Le rendu de droite à gauche est géré par WhatsApp, pas par vous. N'injectez pas de caractères de contrôle directionnels. En revanche, vérifiez le rendu d'un modèle dont le corps arabe contient une variable latine — un numéro de facture comme
INV-2026-0412inséré dans une phrase arabe peut se réordonner visuellement de façon surprenante. Envoyez-vous un vrai message de test avant de valider le texte.
Étape 7 : brancher l'agent IA
Passons à la partie intéressante. Nous utilisons Claude avec un prompt système conçu pour un contexte commercial du Golfe et, surtout, nous conservons l'état de conversation par numéro de téléphone.
// lib/agent/whatsapp-agent.ts
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
const SYSTEM_PROMPT = `Tu es l'assistant service client d'une entreprise en Arabie saoudite, et tu réponds sur WhatsApp.
Langue : réponds dans la langue du client. S'il écrit en arabe, réponds dans un arabe standard moderne clair, qui se lit naturellement pour un public du Golfe. Ne traduis ni les noms propres, ni les noms de produits, ni les numéros de commande.
Format : WhatsApp est une conversation, pas une page web. Limite-toi à deux ou trois phrases courtes. Pas de titres markdown, pas de listes à puces, pas de tableaux. Un client qui lit sur son téléphone doit trouver la réponse dès la première ligne.
Périmètre : réponds aux questions sur les produits, les prix, la disponibilité, les horaires et l'adresse. Si tu ne sais pas quelque chose, dis-le simplement et propose de mettre le client en relation avec un collègue. N'invente jamais un prix, une date de livraison ou une quantité en stock.
Transfert : si le client est mécontent, demande à parler à une personne, ou soulève un remboursement ou une réclamation, réponds par une courte phrase de prise en compte puis appelle l'outil escalate_to_human.`
type Turn = { role: 'user' | 'assistant'; content: string }
const conversations = new Map<string, Turn[]>()
const MAX_TURNS = 20
export async function respond(from: string, message: string): Promise<string> {
const history = conversations.get(from) ?? []
const messages: Turn[] = [...history, { role: 'user', content: message }]
const response = await client.messages.create({
model: 'claude-opus-5',
max_tokens: 1024,
system: SYSTEM_PROMPT,
thinking: { type: 'adaptive' },
output_config: { effort: 'low' },
messages,
})
const reply = response.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('\n')
.trim()
conversations.set(from, [
...messages,
{ role: 'assistant', content: reply },
].slice(-MAX_TURNS))
return reply || 'عذراً، لم أفهم رسالتك. هل يمكنك إعادة صياغتها؟'
}Quelques choix délibérés ici.
effort: 'low' convient à cette charge de travail. Les réponses de service client sont courtes et les questions rarement difficiles ; un effort faible donne des réponses rapides et bien cadrées pour une fraction des jetons. Augmentez-le si votre agent doit raisonner sur un catalogue produits ou un document de politique interne.
La réflexion adaptative reste activée. Elle coûte peu à effort faible et améliore nettement le jugement du modèle sur les moments où il ne faut pas répondre — ce qui compte davantage que l'éloquence en service client.
Le prompt système lui impose la concision. Laissé à lui-même, un modèle performant rédige une réponse bien structurée de trois paragraphes avec des titres. Sur WhatsApp, cela se lit comme un mur de texte et les clients décrochent à la deuxième ligne. Les consignes explicites de longueur et de format font un vrai travail ici.
L'état de conversation est de nouveau en mémoire, et c'est de nouveau une commodité mono-instance. Persistez-le avec le numéro de téléphone comme clé, et définissez une politique de rétention — les conversations WhatsApp contiennent des données personnelles, et la PDPL saoudienne comme l'INPDP tunisienne ont leur mot à dire sur la durée de conservation.
Étape 8 : le transfert à un humain
Un agent IA incapable d'admettre son échec est pire que pas d'agent du tout. C'est le chemin d'escalade qui fait que les clients accordent leur confiance à la partie automatisée.
Donnez un outil au modèle plutôt que de compter sur une phrase magique :
// lib/agent/tools.ts
export const escalateTool = {
name: 'escalate_to_human',
description:
"Transférer cette conversation à un collègue humain. Appelle cet outil quand le client demande explicitement une personne, exprime de la frustration, ou soulève un remboursement, une réclamation ou un litige de compte. Ne l'appelle pas pour les questions ordinaires auxquelles tu peux répondre.",
input_schema: {
type: 'object' as const,
properties: {
reason: {
type: 'string' as const,
description: "Une phrase courte expliquant pourquoi un humain est nécessaire.",
},
urgency: {
type: 'string' as const,
enum: ['normal', 'high'],
},
},
required: ['reason', 'urgency'],
},
}Notez que la description dit à la fois quand l'appeler et quand ne pas l'appeler. Les modèles Claude actuels suivent les descriptions d'outils de très près, et une description qui se contente de « escalader si nécessaire » produit un agent qui escalade en permanence.
Associez l'outil à une vérification des horaires pour que la promesse faite soit honnête :
// lib/agent/hours.ts
// Riyad est à UTC+3 toute l'année — pas d'heure d'été.
export function withinBusinessHours(now = new Date()): boolean {
const riyadhHour = (now.getUTCHours() + 3) % 24
const day = now.getUTCDay() // 0 dimanche ... 6 samedi
const isWeekend = day === 5 || day === 6 // vendredi et samedi
return !isWeekend && riyadhHour >= 9 && riyadhHour < 18
}Le week-end dans le Golfe, c'est vendredi et samedi. Livrer un bot qui annonce à un client saoudien, un jeudi soir, que « notre équipe répondra lundi » est une petite erreur qui se lit comme une grande.
Étape 9 : assembler le handler POST
Tout converge ici — et l'ordre compte plus qu'il n'y paraît.
// app/api/whatsapp/webhook/route.ts (suite)
import { after } from 'next/server'
import { isValidSignature } from '@/lib/whatsapp/verify'
import { extractMessages } from '@/lib/whatsapp/parse'
import { alreadyHandled } from '@/lib/whatsapp/seen'
import { sendText, markAsRead } from '@/lib/whatsapp/send'
import { respond } from '@/lib/agent/whatsapp-agent'
export const runtime = 'nodejs'
export async function POST(request: Request) {
// 1. Lire le corps UNE SEULE FOIS en texte. Hacher un objet re-sérialisé échoue.
const raw = await request.text()
// 2. Rejeter les falsifications avant tout travail.
if (!isValidSignature(raw, request.headers.get('x-hub-signature-256'))) {
return new Response('Invalid signature', { status: 401 })
}
const messages = extractMessages(JSON.parse(raw))
// 3. Faire le travail lent après l'envoi de la réponse.
after(async () => {
for (const message of messages) {
if (alreadyHandled(message.wamid)) continue
try {
await markAsRead(message.wamid)
const reply = await respond(message.from, message.text)
await sendText(message.from, reply)
} catch (error) {
console.error('[whatsapp] handler failed', {
wamid: message.wamid,
error,
})
}
}
})
// 4. Accuser réception immédiatement.
return new Response(null, { status: 200 })
}La règle structurelle : accusez réception vite, travaillez ensuite. Meta attend une réponse en quelques secondes et relance s'il ne l'obtient pas. Un appel au modèle plus un envoi sortant dépassent facilement ce budget, et la relance produit une réponse en double par-dessus une réponse lente.
after() de Next.js exécute un callback une fois la réponse envoyée, ce qui est exactement la forme voulue. Sur d'autres plateformes, poussez le message dans une file et renvoyez 200 — le motif est le même, seul le mécanisme change.
runtime = 'nodejs' est obligatoire, pas optionnel : timingSafeEqual de node:crypto n'existe pas sur le runtime Edge.
Notez que nous absorbons les erreurs dans la boucle plutôt que de laisser un message défectueux tuer le lot. Renvoyer autre chose qu'un 2xx depuis le handler dit à Meta de rejouer toute la charge utile, y compris les messages auxquels vous avez déjà répondu.
Tester votre implémentation
Vérifiez chaque couche indépendamment plutôt que de tester toute la chaîne d'un coup.
Poignée de main de vérification — simulez ce qu'envoie Meta :
curl "https://votre-domaine.com/api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=VOTRE_TOKEN&hub.challenge=test123"
# Attendu, en texte brut : test123Rejet de signature — confirmez qu'une requête non signée est refusée :
curl -X POST https://votre-domaine.com/api/whatsapp/webhook \
-H 'content-type: application/json' \
-d '{"object":"whatsapp_business_account","entry":[]}'
# Attendu : 401 Invalid signatureSi cela renvoie 200, votre contrôle de signature n'est pas branché et votre endpoint est ouvert à Internet.
Envoi sortant — depuis le terminal, en contournant entièrement votre application :
curl -X POST "https://graph.facebook.com/v23.0/$WHATSAPP_PHONE_NUMBER_ID/messages" \
-H "Authorization: Bearer $WHATSAPP_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "966555555555",
"type": "text",
"text": { "body": "اختبار من الـ Cloud API" }
}'Cela isole les problèmes d'identifiants et de permissions des bugs applicatifs. Si le curl fonctionne et pas votre application, le problème est dans votre code, pas dans la console Meta.
De bout en bout — écrivez au numéro de votre entreprise depuis un vrai téléphone et surveillez les logs. Envoyez en arabe, puis en anglais, puis une note vocale, et vérifiez que l'agent traite chaque cas comme prévu.
Dépannage
La vérification du webhook échoue sans erreur utile. Neuf fois sur dix, l'endpoint n'est pas accessible publiquement, ou vous avez renvoyé du JSON au lieu de texte brut. Testez d'abord l'URL de vérification en curl depuis l'extérieur de votre réseau.
Toutes les vérifications de signature échouent. Vous hachez presque certainement un corps re-sérialisé. await request.text() une fois, vérifiez exactement cette chaîne, puis JSON.parse. Confirmez aussi que vous utilisez bien l'App Secret, pas le jeton d'accès.
Erreur 131047 : message de reprise de contact. La fenêtre de 24 heures est fermée. Envoyez un modèle approuvé au lieu de texte libre.
Erreur 132000 : nombre de paramètres incohérent. Le nombre de variables envoyées ne correspond pas au modèle approuvé. Comptez les emplacements dans la version approuvée par Meta, pas dans celle de vos notes.
Erreur 100 avec « Unsupported post request ». Généralement un mauvais PHONE_NUMBER_ID — beaucoup collent l'identifiant du compte WhatsApp Business à la place. Ce sont deux valeurs distinctes qui semblent tout aussi plausibles.
Les messages partent mais n'arrivent jamais. Regardez les webhooks statuses. Un statut failed porte un objet d'erreur qui explique pourquoi, et c'est souvent que le destinataire n'a jamais écrit à votre numéro et que vous êtes hors fenêtre.
Réponses en double à un seul message client. Votre handler est trop lent et Meta relance, ou votre déduplication est par instance alors que vous en exécutez plusieurs. Corrigez d'abord la latence d'accusé de réception, puis sortez le stockage de déduplication de la mémoire.
Pour aller plus loin
Les prolongements naturels :
- Messages interactifs — boutons et menus déroulants réduisent fortement l'ambiguïté du texte libre, et en arabe ils contournent entièrement la variation dialectale. Même endpoint, avec
type: 'interactive'. - Notes vocales — transcrivez l'audio entrant avant de le passer à l'agent. Sur les marchés du Golfe, une part significative des clients préfère parler qu'écrire.
- Utilisation d'outils sur vos vrais systèmes — l'agent devient réellement utile dès qu'il peut consulter un stock ou un statut de commande réels. Notre guide sur connecter l'IA à vos systèmes existants couvre cette couche d'intégration, et le piège de l'ERP explique pourquoi l'intégration l'emporte sur le remplacement.
- Orchestration low-code — si vous préférez assembler cela visuellement plutôt qu'en TypeScript, notre tutoriel d'automatisation multi-agents avec n8n construit des flux comparables dans un moteur de workflow.
- Persistance et conformité — les conversations sont des données personnelles. Tranchez la rétention, le chiffrement et les accès avant de passer à l'échelle, pas après.
Pour la vision stratégique plus large du déploiement d'agents dans la région, voyez les agents IA pour les entreprises MENA.
Conclusion
Le Cloud API de WhatsApp n'est pas une API difficile. C'est un endpoint REST bien documenté avec un webhook. Ce qui rend les projets WhatsApp compliqués, c'est tout ce qui l'entoure : une vérification d'entreprise qui prend une semaine, une fenêtre de 24 heures qui dicte toute votre architecture de messagerie, des modèles à faire approuver avant le moindre rappel, et une surface conversationnelle arabophone que la traduction automatique gère mal.
Le code de ce tutoriel couvre précisément ce qu'un abonnement SaaS vous cache — et c'est exactement ce que vous devez maîtriser lorsque l'intégration doit atteindre votre système de stock, respecter vos obligations de résidence des données, ou traiter un dialecte sur lequel le modèle de votre prestataire n'a jamais été entraîné.
Si vous hésitez entre construire en interne et acheter une plateforme, la question décisive n'est généralement pas le coût. C'est de savoir si la conversation doit toucher des systèmes que vous contrôlez. Si c'est le cas, la plateforme devient un goulot d'étranglement en moins d'un trimestre.
Vous construisez un canal WhatsApp pour une entreprise saoudienne ou tunisienne ? Parlons-en — nous examinerons votre configuration actuelle et vous dirons honnêtement s'il s'agit d'une intégration de deux semaines ou de deux mois, avant que quiconque ne signe quoi que ce soit.