In Saudi Arabia and across the Gulf, WhatsApp is not a marketing channel. It is the channel. It is where a customer asks whether you have the part in stock, where a clinic confirms an appointment, and where a contractor sends a quotation. Every serious business here already runs on it — usually through a personal phone that one employee owns and takes home at 5pm.
The gap between that reality and a proper integration is where most projects stall. Search for "WhatsApp bot" in Arabic and you will find forty no-code SaaS platforms selling monthly subscriptions, and almost nothing that shows a developer how the actual Meta API works. This tutorial is the missing piece: the Cloud API, at the code level, with a working AI agent on the other end.
What You Will Build
A production-shaped WhatsApp agent running on Next.js 15 App Router:
- A webhook endpoint that Meta can verify and that rejects forged requests
- Typed parsing of inbound messages, with idempotency so retries do not double-reply
- An outbound client for free-form text, read receipts, and approved templates
- Correct handling of the 24-hour customer service window
- An Arabic-first AI agent powered by Claude that understands Gulf dialect and knows when to stop talking
- A clean handoff path to a human being
Prerequisites
- Node.js 20+ and a Next.js 15 project using the App Router
- A Meta Business account with a verified business. In Saudi Arabia this means your commercial registration (السجل التجاري); in Tunisia, your patente. Verification takes days, not minutes — start it before you write any code.
- A phone number that is not currently active on the WhatsApp consumer app or WhatsApp Business app. Once a number moves to the Cloud API, it leaves those apps.
- An Anthropic API key for the agent portion
- A public HTTPS URL. Meta will not call an HTTP endpoint or a localhost address. Use a tunnel such as
ngrok http 3000during development.
Cost note. Meta gives you a free tier of conversations per month, then bills per message by category and destination country. Saudi and Tunisian rates differ. Check the current pricing page for your market before you promise anyone a number — pricing has changed twice in the last two years.
Step 1: Set Up the Meta App
In the Meta for Developers console, create an app of type Business, then add the WhatsApp product to it. You will land on a quickstart page that hands you four values. Put them in .env.local immediately and never hardcode them:
# .env.local
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321098765
WHATSAPP_ACCESS_TOKEN=EAAJB...
WHATSAPP_APP_SECRET=a1b2c3d4e5f6...
WHATSAPP_VERIFY_TOKEN=pick-a-long-random-string-yourself
ANTHROPIC_API_KEY=sk-ant-...Two of these deserve explanation.
WHATSAPP_VERIFY_TOKEN is not issued by Meta. You invent it, and you paste the same value into the Meta webhook configuration form. It exists so that when Meta calls your endpoint to verify it, you can confirm the call really came from your own configuration.
WHATSAPP_APP_SECRET lives under App Settings → Basic. It is the HMAC key Meta uses to sign every webhook payload. Without it you cannot tell a genuine webhook from anyone on the internet who found your URL.
The temporary access token on the quickstart page expires in 24 hours. For anything beyond a first test, create a System User under Business Settings, assign it your WhatsApp Business Account with full control, and generate a permanent token. Do this early — discovering your bot died overnight because of a dev token is a bad Tuesday.
Add a small config module so the rest of the code fails loudly on a missing variable rather than sending requests to 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}`Step 2: The Webhook Verification Handshake
When you save a webhook URL in the Meta console, Meta immediately sends a GET request with three query parameters: hub.mode, hub.verify_token, and hub.challenge. You must echo the challenge back as plain text — and only if the token matches.
// 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 })
}Three mistakes break this handshake, and all three produce the same unhelpful error in the Meta console:
- Returning JSON. Meta compares the raw response body against the challenge string.
Response.json(challenge)wraps it in quotes and fails. - A trailing slash mismatch. The URL in the console must match your route exactly.
- Verifying deployment. The endpoint has to be live and publicly reachable at the moment you press Save, not after.
Once verification succeeds, subscribe to the messages field in the webhook configuration. Nothing arrives until you do.
Step 3: Validate the Signature
This is the step most tutorials skip, and it is the one that matters. Your webhook URL is a public HTTPS endpoint. Anyone who discovers it can POST a fake "customer message" and make your AI agent respond to a stranger — or worse, trigger whatever business logic sits behind it.
Meta signs every POST with an HMAC-SHA256 of the raw request body, keyed on your app secret, in the X-Hub-Signature-256 header. You must recompute it and compare.
// 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)
// Length check first: timingSafeEqual throws on mismatched buffer lengths.
if (received.length !== expected.length) return false
return timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'))
}Two details are load-bearing.
You must hash the raw body, byte for byte. If you call await request.json() and then re-serialize the object, key ordering and whitespace will differ from what Meta signed, and every signature will fail. Read the body as text once, verify it, then parse the string you already have.
Use timingSafeEqual, not ===. A plain string comparison returns as soon as it finds a differing character, and that timing difference is enough to let an attacker recover a valid signature one byte at a time. The length guard before it is necessary because timingSafeEqual throws rather than returning false when buffers differ in size.
Step 4: Parse Inbound Messages
The webhook payload is deeply nested and carries more than customer messages. Here is a real text message, trimmed:
{
"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": "Customer Name" },
"wa_id": "966555555555"
}],
"messages": [{
"from": "966555555555",
"id": "wamid.HBgL...",
"timestamp": "1786000000",
"type": "text",
"text": { "body": "Do you have this in stock?" }
}]
}
}]
}]
}The critical distinction: a value object containing a messages array is an inbound customer message. A value containing a statuses array is a delivery receipt for something you sent — sent, delivered, read, or failed. If you do not separate these, your agent will happily try to reply to its own read receipts.
// 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
// Absent `messages` means this is a status callback, not a customer message.
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
}We filter to type === 'text' here for clarity. Real deployments also see image, audio, document, location, button, and interactive — audio in particular is worth handling in Arabic markets, where voice notes are often the default way customers communicate. Handle them the same way: branch on type and extract the relevant payload.
Idempotency
Meta retries a webhook if you do not return a 2xx quickly. Those retries carry the same wamid. Without deduplication, one customer question becomes three identical AI replies and three billing events.
Every message ID is globally unique, so use it as the deduplication key:
// 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()
// Opportunistic cleanup so the map does not grow without bound.
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
}An in-memory map is fine for a single instance. The moment you run more than one — any serverless platform, any horizontally scaled deployment — move this to Redis or a database with a unique constraint on wamid. Two instances each holding their own map deduplicate nothing.
Step 5: The Outbound Client
Sending is a POST to /PHONE_NUMBER_ID/messages with a bearer token. The shape of the body depends on the message type.
// 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 is small and worth doing. The blue ticks appearing within a second tell the customer a real system received their message, which buys you the few seconds the model needs to think.
Note the to field: an international number with no +, no spaces, no dashes. 966555555555, not +966 55 555 5555. The from field on inbound messages is already in this format, so echoing it back is safe.
Step 6: The 24-Hour Window and Templates
This is the rule that shapes every WhatsApp product, and misunderstanding it is the most common cause of a launch failing review.
You cannot message a customer whenever you like. When a user sends you a message, a 24-hour customer service window opens. Inside that window you may send free-form messages — any text, any content. Every new message from the user resets the timer to a fresh 24 hours. Once it closes, free-form messages are rejected, and you may only send a pre-approved template.
For an inbound-driven agent this is mostly invisible: the customer messaged you, so the window is open. It becomes visible the moment the business wants to send an appointment reminder, an order-status update, or a follow-up on a quotation.
Templates are submitted through the WhatsApp Manager and reviewed by Meta, typically within a few hours. Each has a name, a language code, and numbered variables.
Register the Arabic version with the language code ar — with the body written naturally, not machine-translated:
مرحباً {{1}}، طلبك رقم {{2}} جاهز للاستلام من فرعنا. شكراً لثقتك بنا.
Send it with parameters in order:
// lib/whatsapp/send.ts (continued)
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 } : {}),
},
})
}Three things that reliably cost teams a day each:
- Parameters are positional.
params[0]fills the first placeholder,params[1]the second. There are no named variables. A mismatch between the count you send and the count in the approved template returns a132000error. - The language code must match the approved template exactly. A template approved as
arcannot be sent asar_SA. They are different templates as far as the API is concerned. - Right-to-left rendering is handled by WhatsApp, not by you. Do not inject directional control characters. Do check how a template renders when an Arabic body contains a Latin variable — an order number like
INV-2026-0412dropped into an Arabic sentence can visually reorder in ways that surprise you. Send yourself a real test message before approving the copy.
Step 7: Wire Up the AI Agent
Now the interesting part. We use Claude with a system prompt built for a Gulf business context, and — critically — we keep conversation state per phone number.
// lib/agent/whatsapp-agent.ts
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
const SYSTEM_PROMPT = `You are the customer service assistant for a business in Saudi Arabia, replying on WhatsApp.
Language: reply in the language the customer wrote in. If they write in Arabic, reply in clear Modern Standard Arabic that reads naturally to a Gulf audience. Do not translate proper nouns, product names, or order numbers.
Format: WhatsApp is a chat, not a webpage. Keep replies to two or three short sentences. No markdown headings, no bullet lists, no tables. A customer reading on a phone should get the answer in the first line.
Scope: answer questions about products, pricing, availability, working hours, and location. If you do not know something, say so plainly and offer to connect the customer with a colleague. Never invent a price, a delivery date, or a stock quantity.
Handoff: if the customer is angry, asks to speak to a person, or raises anything involving a refund or a complaint, reply with one short acknowledgement and call the escalate_to_human tool.`
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 || 'عذراً، لم أفهم رسالتك. هل يمكنك إعادة صياغتها؟'
}A few deliberate choices here.
effort: 'low' is right for this workload. Customer service replies are short and the questions are rarely hard; low effort gives you fast, well-scoped answers at a fraction of the tokens. Raise it if your agent has to reason over a product catalogue or a policy document.
Adaptive thinking stays on. It costs little at low effort and noticeably improves the model's judgement about when not to answer — which matters more than eloquence in customer service.
The system prompt tells it to be short. Left alone, a capable model writes a well-organised three-paragraph response with headers. On WhatsApp that reads as a wall of text and customers stop reading at line two. Explicit length and formatting instructions are doing real work here.
Conversation state is in memory again, and again that is a single-instance convenience. Persist it keyed on the phone number, and set a retention policy — WhatsApp conversations contain personal data, and both Saudi PDPL and Tunisia's INPDP have things to say about how long you keep it.
Step 8: Handoff to a Human
An AI agent that cannot admit defeat is worse than no agent. The escalation path is what makes customers trust the automated part.
Give the model a tool rather than relying on it to emit a magic phrase:
// lib/agent/tools.ts
export const escalateTool = {
name: 'escalate_to_human',
description:
'Hand this conversation to a human colleague. Call this when the customer explicitly asks for a person, expresses frustration, or raises a refund, complaint, or account dispute. Do not call it for ordinary questions you can answer.',
input_schema: {
type: 'object' as const,
properties: {
reason: {
type: 'string' as const,
description: 'One short sentence on why this needs a human.',
},
urgency: {
type: 'string' as const,
enum: ['normal', 'high'],
},
},
required: ['reason', 'urgency'],
},
}Note the description says both when to call it and when not to. Current Claude models follow tool descriptions closely, and a description that only says "escalate when needed" produces an agent that escalates constantly.
Pair the tool with a business-hours check so the promise you make is honest:
// lib/agent/hours.ts
// Riyadh is UTC+3 year-round — no daylight saving.
export function withinBusinessHours(now = new Date()): boolean {
const riyadhHour = (now.getUTCHours() + 3) % 24
const day = now.getUTCDay() // 0 Sunday ... 6 Saturday
const isWeekend = day === 5 || day === 6 // Friday and Saturday
return !isWeekend && riyadhHour >= 9 && riyadhHour < 18
}The Gulf weekend is Friday and Saturday. Shipping a bot that tells a Saudi customer "our team will reply on Monday" on a Thursday evening is a small mistake that reads as a large one.
Step 9: Assemble the POST Handler
Everything comes together here — and the ordering matters more than it looks.
// app/api/whatsapp/webhook/route.ts (continued)
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. Read the body ONCE as text. Hashing a re-serialized object fails.
const raw = await request.text()
// 2. Reject forgeries before doing any work.
if (!isValidSignature(raw, request.headers.get('x-hub-signature-256'))) {
return new Response('Invalid signature', { status: 401 })
}
const messages = extractMessages(JSON.parse(raw))
// 3. Do the slow work after the response is sent.
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. Acknowledge immediately.
return new Response(null, { status: 200 })
}The structural rule: acknowledge fast, work afterwards. Meta expects a response in a handful of seconds and retries when it does not get one. A model call plus an outbound send can easily exceed that budget, and the retry produces a duplicate reply on top of a slow one.
Next.js after() runs a callback once the response has been flushed, which is exactly the shape we want. On other platforms, push the message onto a queue and return 200 — the pattern is the same, only the mechanism differs.
runtime = 'nodejs' is required, not optional: node:crypto's timingSafeEqual is unavailable on the Edge runtime.
Note that we swallow errors inside the loop rather than letting one bad message kill the batch. Returning a non-2xx from the handler tells Meta to retry the whole payload, including the messages you already answered.
Testing Your Implementation
Verify each layer independently rather than testing the whole chain at once.
Verification handshake — simulate what Meta sends:
curl "https://your-domain.com/api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=test123"
# Expect the plain text: test123Signature rejection — confirm an unsigned request is refused:
curl -X POST https://your-domain.com/api/whatsapp/webhook \
-H 'content-type: application/json' \
-d '{"object":"whatsapp_business_account","entry":[]}'
# Expect: 401 Invalid signatureIf that returns 200, your signature check is not wired in, and your endpoint is open to the internet.
Outbound send — from the terminal, bypassing your app entirely:
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" }
}'This isolates credential and permission problems from application bugs. If the curl works and your app does not, the problem is in your code, not in Meta's console.
End to end — message your business number from a real phone and watch the logs. Send in Arabic, then in English, then a voice note, and confirm the agent handles each the way you intended.
Troubleshooting
Webhook verification fails with no useful error. Ninety percent of the time the endpoint is not publicly reachable, or you returned JSON instead of plain text. Curl the verification URL from outside your network first.
Every signature check fails. You are almost certainly hashing a re-serialized body. await request.text() once, verify that exact string, then JSON.parse it. Also confirm you are using the App Secret, not the access token.
Error 131047: re-engagement message. The 24-hour window has closed. Send an approved template instead of free-form text.
Error 132000: parameter count mismatch. The number of variables you sent does not match the approved template. Count the placeholders in the version Meta approved, not the version in your notes.
Error 100 with "Unsupported post request." Usually a wrong PHONE_NUMBER_ID — people often paste the WhatsApp Business Account ID instead. They are different values that look equally plausible.
Messages send but never arrive. Check the statuses webhooks. A failed status carries an error object that explains why, and it is often that the recipient has never messaged your number and you are outside the window.
Duplicate replies to one customer message. Your handler is too slow and Meta is retrying, or your deduplication is per-instance while you run several instances. Fix the acknowledgement latency first, then move the dedup store out of memory.
Next Steps
The natural extensions from here:
- Interactive messages — buttons and list menus reduce free-text ambiguity substantially, and in Arabic they sidestep dialect variation entirely. Same endpoint,
type: 'interactive'. - Voice notes — transcribe inbound audio before passing it to the agent. In Gulf markets a meaningful share of customers prefer speaking to typing.
- Tool use against your real systems — the agent becomes genuinely useful once it can check actual stock or an actual order status. Our guide to connecting AI to your existing business systems covers that integration layer, and the ERP trap explains why integration beats replacement.
- Low-code orchestration — if you would rather assemble this visually than in TypeScript, our n8n multi-agent automation tutorial builds comparable flows in a workflow engine.
- Persistence and compliance — conversations are personal data. Decide retention, encryption, and access before you scale, not after.
For the broader strategic picture of deploying agents in this region, see AI agents for MENA enterprises.
Conclusion
The WhatsApp Cloud API is not a difficult API. It is a well-documented REST endpoint with a webhook. What makes WhatsApp projects hard is everything around it: business verification that takes a week, a 24-hour window that dictates your entire messaging architecture, templates that need approval before your first reminder can go out, and an Arabic conversational surface that machine translation handles badly.
The code in this tutorial covers the parts a SaaS subscription hides from you — and those are exactly the parts you need to own when the integration has to reach your inventory system, respect your data-residency obligations, or handle a dialect your vendor's model was never trained on.
If you are weighing whether to build this in-house or buy a platform, the deciding question is usually not cost. It is whether the conversation needs to touch systems you control. If it does, a platform becomes the bottleneck within a quarter.
Building a WhatsApp channel for a Saudi or Tunisian business? Talk to us — we will look at your current setup and tell you honestly whether this is a two-week integration or a two-month one, before anyone signs anything.