Free build guide
A complete, working build. Meta Cloud API setup, a signed webhook, Claude with memory and tools, human handoff, the real monthly bill, and the whole file at the end. Nothing held back.
How do you connect Claude to WhatsApp?
You connect Claude to WhatsApp by putting a webhook between them. Register a Meta app on the WhatsApp Cloud API, attach a phone number, and point a public HTTPS webhook at your server. Meta POSTs every inbound message to that URL, signed with your app secret. Your handler verifies the signature, answers 200 immediately so Meta does not retry and double-send, then calls the Claude Messages API with the full conversation history and posts the reply back to the Graph API messages endpoint. Claude is stateless, so you store the history yourself, keyed by phone number. Add tool definitions when the agent needs to check a calendar or look up a price instead of guessing. At 2026 prices a typical eight-turn conversation costs about two US cents on Claude Sonnet 5, and Meta charges nothing at all for replies sent inside the 24-hour window that opens when the customer writes first.
The whole build is here, including the complete file. Nothing is held back for a paid version.
There are exactly two ways to get a message from WhatsApp into your code. Pick wrong and you rebuild in three weeks.
Meta hosts the connection. You register an app, attach a phone number, point a webhook at your server, and Meta POSTs you every inbound message.
Good: it does not disconnect. It survives your server restarting, your laptop closing, and Meta's own updates. It is the only option a real business can run on.
Bad: setup is a bureaucratic slog. Business verification can take days. You get a 24-hour reply window and template approval rules (section 8). A number already active on the consumer WhatsApp app cannot be used until you migrate or delete it.
These drive WhatsApp Web. You scan a QR code with your phone, the library holds the session, and you read and write messages as if you were the browser.
Good: first message in about ten minutes. No verification, no Meta app, no templates, no 24-hour window. Any number, including one already on your phone.
Bad: it is an unofficial logged-in session. Sessions drop and need re-pairing, sometimes silently. Meta bans numbers for automated behaviour at its own discretion, with no warning and no appeal. Session state becomes a file you have to back up. Every WhatsApp Web update is a potential outage.
This guide uses the Cloud API. Sections 3 and 4 work unchanged on a QR transport; only the receive-and-send edges differ.
Meta's Graph API is versioned in the URL. Pin one explicitly and put it in a constant.
const GRAPH = 'v26.0'; // check the Graph API changelog before bumping
Never call an unversioned endpoint, and never let a tutorial's copy-pasted version become your production version by accident.
This is the step where most people quit. It is nine moves. None are hard; they are just scattered across three different Meta consoles that do not link to each other.
Go to the Meta developers site, My Apps, Create App, and pick the WhatsApp use case. You will be asked for a Business portfolio; create one if you have none.
The app dashboard hands you a free Meta-provided test number immediately. It can only message up to five recipients you add by hand, but you do not need business verification to use it. Build everything against this number. Do not touch your real number until section 3 works end to end.
From WhatsApp, API Setup, copy the phone number ID, which is the number that sends and goes in the send URL (it is not the phone number itself), and the WhatsApp Business Account ID, which owns the number and is what you need for templates and number-level settings.
The same screen gives you a token that expires in 24 hours. Use it to prove the pipe works. Do not put it in your .env and forget it, because the outage arrives tomorrow and looks like a bug.
In Business Settings, a different console, go to Users, System Users, Add. Give it a name and the Admin role. Then, on that system user, add your app and your WhatsApp Business Account as assets with full control, and generate a new token for your app with whatsapp_business_messaging and whatsapp_business_management checked and expiry set to never.
Copy the token. It is shown once. This is the token your server uses forever.
They get confused constantly, and each failure looks different.
| Secret | Where it comes from | What it does | Failure looks like |
|---|---|---|---|
| Access token | System User (step 5) | Authenticates your calls TO Meta | 401 on send |
| App secret | App Dashboard, Settings, Basic | Verifies calls FROM Meta are real | Every webhook rejected as unsigned |
| Verify token | A string you invent | One-time handshake when you register the webhook | Webhook will not save |
Drag the table sideways to see every column.
The verify token is not a secret Meta gives you. You make it up and type the same string in both places.
Meta requires a public HTTPS URL with a valid certificate. Localhost will not work. Use ngrok, a Cloudflare Tunnel, or a real deployment. Whatever you pick, the URL must be reachable before the next step, because Meta calls it during registration.
In the App Dashboard, WhatsApp, Configuration, Edit: set the callback URL to your public address ending in /webhook and paste the verify token you invented. Click Verify and save. Meta immediately GETs your URL, so the handler from section 3 must already be running. Write it first, then come back.
Then Manage the webhook fields and subscribe to messages. Skipping this subscription is the single most common cause of "my webhook saved but nothing arrives". Saving the URL and subscribing to fields are two separate actions, and the interface does not tell you the second one is missing.
The number must not be active on the consumer WhatsApp app. If it is, delete that account first and wait, or use a fresh number. Meta verifies by SMS or voice call. To raise your daily messaging limit beyond the starting tier you need business verification, which asks for legal documents on the business portfolio and takes days. Start it early; it runs in the background while you build.
Two handlers on one URL. The GET runs once, ever. The POST runs for the rest of your life.
Meta sends hub.mode, hub.verify_token and hub.challenge. You compare the token and echo the challenge back as plain text.
app.get('/webhook', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.VERIFY_TOKEN) {
return res.status(200).type('text/plain').send(challenge);
}
return res.sendStatus(403);
});
Echo the challenge raw. Not JSON, not wrapped in an object. This handler exists only so Meta can prove you own the URL.
Meta signs every POST with X-Hub-Signature-256: an HMAC-SHA256 of the raw request body, keyed with your app secret, prefixed sha256=.
The word raw is doing real work. If your framework has already parsed the JSON and you re-serialize it to check the signature, key order and whitespace shift and every request fails verification. Capture the raw buffer before parsing.
import express from 'express';
import crypto from 'crypto';
const app = express();
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
function verifySignature(req) {
const header = req.get('X-Hub-Signature-256') || '';
const expected = header.startsWith('sha256=') ? header.slice(7) : header;
if (!expected) return false;
const actual = crypto
.createHmac('sha256', process.env.APP_SECRET)
.update(req.rawBody)
.digest('hex');
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
}
Use a constant-time compare. A plain equality check on an HMAC leaks timing, and it is one line either way. Skipping this check entirely means anyone who learns your URL can POST fake customer messages and your bot will answer them, on your token, at your cost.
This is the part that bites everyone. Meta wants a fast 2xx. If you hold the connection open while you call Claude, Meta treats the delivery as failed and retries with backoff. Your handler runs again on the same message. Claude answers again. The customer gets two replies, then three.
app.post('/webhook', (req, res) => {
if (!verifySignature(req)) return res.sendStatus(401);
res.sendStatus(200); // ack FIRST
handleEvent(req.body).catch(err => console.error('[wa] handler', err));
});
On Cloudflare Workers, or any platform that kills the isolate when the response returns, the equivalent is ctx.waitUntil(handleEvent(body)): the response goes out immediately and the async work is still allowed to finish. Never await your AI call before responding.
The payload is nested deeper than it needs to be, and the same envelope also carries delivery receipts under statuses, which are not messages. Read past them or your bot will answer its own read receipts.
function extractMessages(body) {
const out = [];
for (const entry of body.entry || []) {
for (const change of entry.changes || []) {
const value = change.value || {};
if (!value.messages) continue; // statuses, not messages
const phoneNumberId = value.metadata?.phone_number_id;
for (const msg of value.messages) {
if (msg.type !== 'text') continue; // images/audio: section 6
out.push({
id: msg.id, // wamid.XXXX
from: msg.from, // E.164, no +
text: msg.text.body,
phoneNumberId,
});
}
}
}
return out;
}
Because of the retries above, and because Meta can deliver the same event more than once on its own, you will see the same wamid twice. Claim it before you answer.
const seen = new Set(); // production: Redis SETNX, or a unique DB index
function claim(id) {
if (seen.has(id)) return false;
seen.add(id);
return true;
}
An in-memory Set works on one process. The moment you run two instances it stops working, and the symptom is intermittent double replies that you cannot reproduce locally. Use a shared store with an atomic set-if-absent.
async function sendText(phoneNumberId, to, body) {
const r = await fetch(
`https://graph.facebook.com/${GRAPH}/${phoneNumberId}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { preview_url: false, body },
}),
},
);
if (!r.ok) console.error('[wa] send failed', r.status, await r.text());
return r.ok;
}
messaging_product is required and easy to forget; without it you get a confusing 400 that does not name the missing field. Log the failure body, because Meta's error responses are actually specific and staring at a bare 400 when the response says exactly which field is wrong wastes hours.
You have a message coming in and a way to send one out. Now put a brain in the middle.
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const SYSTEM = `You are the assistant for [BUSINESS NAME].
You answer customers on WhatsApp.
Rules:
- Reply in the customer's language.
- Two or three sentences, maximum. This is a chat, not an email.
- Never invent prices, hours, availability, or policy. If you do not know, say
you will check and ask for the detail you are missing.
- Plain text only. No markdown headings, no bold, no bullet characters.`;
async function askClaude(history) {
const res = await anthropic.messages.create({
model: 'claude-sonnet-5',
max_tokens: 400,
system: SYSTEM,
messages: history,
});
return res.content
.filter(b => b.type === 'text')
.map(b => b.text)
.join('')
.trim();
}
max_tokens is a hard ceiling, not a target. Keep it low here. A model that can write four thousand tokens will occasionally decide to, and a wall of text on WhatsApp reads as spam.
Model choice. Claude Sonnet 5 is the sensible default for customer chat: strong enough to follow a long system prompt, fast enough that the reply lands while the customer is still looking at the screen. Drop to Claude Haiku 4.5 when volume is high and the job is narrow, such as routing, FAQ lookup or classification. Reach for Claude Opus 5 when the agent has to reason over a real knowledge base or negotiate.
The Messages API is stateless. Every call has to carry the whole conversation, or Claude has no idea who it is talking to. This is the single biggest difference between a bot that feels real and one that feels broken.
// Production: a real store keyed by phone number, with a TTL.
const threads = new Map();
function getHistory(from) {
return threads.get(from) || [];
}
function appendTurn(from, role, content) {
const h = getHistory(from);
h.push({ role, content });
// Keep the last 20 turns. Older context is not worth the tokens.
threads.set(from, h.slice(-20));
}
Three rules that are not obvious. Key by phone number, not by message id, because the thread is the person. Trim, because every message replays the whole history, so an untrimmed thread costs more on every single turn and eventually hits the context limit. Set a time to live, because a customer coming back after two weeks does not want the bot referencing a conversation they have forgotten; expire after roughly 24 hours and start fresh.
async function handleEvent(body) {
for (const msg of extractMessages(body)) {
if (!claim(msg.id)) continue;
appendTurn(msg.from, 'user', msg.text);
let reply;
try {
reply = await askClaude(getHistory(msg.from));
} catch (err) {
console.error('[claude] failed', err);
reply = 'Sorry, I had a problem here. Can you send that again?';
}
appendTurn(msg.from, 'assistant', reply);
await sendText(msg.phoneNumberId, msg.from, reply);
}
}
Note the catch. When the API call fails, the customer must still get something. Silence is the worst possible failure mode: they do not know if the business is closed, ignoring them, or broken, and they leave.
Note also that the fallback reply is appended to history as the assistant turn. If you skip that, the history desynchronizes and your next call sends two consecutive user messages, which the API rejects.
Every message from a customer reaches Claude with full conversation context, and the answer goes back to WhatsApp. That is a working AI agent on WhatsApp, in roughly 120 lines. It is also still a chatbot: it can talk, but it cannot do anything.
Section 4 left you with something that can talk but cannot act. It cannot check whether Thursday at three is free. Ask it and it will either say it does not know, or make something up.
Tools fix this. You describe functions Claude can call, Claude decides when to call them, and you run the code.
const TOOLS = [
{
name: 'check_availability',
description:
'Check open appointment slots for a given date. Use this whenever the ' +
'customer asks about scheduling, availability, or a specific day. Never ' +
'guess availability without calling this.',
input_schema: {
type: 'object',
properties: {
date: { type: 'string', description: 'ISO date, YYYY-MM-DD' },
},
required: ['date'],
},
},
{
name: 'book_appointment',
description:
'Book a confirmed slot. Only call after the customer has explicitly ' +
'agreed to a specific date and time.',
input_schema: {
type: 'object',
properties: {
date: { type: 'string', description: 'ISO date, YYYY-MM-DD' },
time: { type: 'string', description: '24h HH:MM' },
name: { type: 'string' },
},
required: ['date', 'time', 'name'],
},
},
];
The description is the prompt. Claude decides whether to call a tool almost entirely from its description, so write it like an instruction to a new employee, not like a docstring. "Never guess availability without calling this" does more work than three paragraphs of system prompt. The same goes for required: every field you mark required is a field Claude will go and ask the customer for before calling. That is free conversation design.
A tool call is not one request. Claude answers with a tool_use stop reason, you run the function, you send the result back, and Claude answers again, possibly with another tool call. It is a loop, and it must have a ceiling.
const HANDLERS = {
check_availability: async ({ date }) => {
const slots = await db.freeSlots(date);
return { date, slots }; // plain JSON, that is all
},
book_appointment: async ({ date, time, name }) => {
const ok = await db.book(date, time, name);
return ok ? { booked: true, date, time } : { booked: false, reason: 'taken' };
},
};
async function askClaude(history) {
const messages = [...history];
for (let hop = 0; hop < 5; hop++) { // ceiling: never `while (true)`
const res = await anthropic.messages.create({
model: 'claude-sonnet-5',
max_tokens: 400,
system: SYSTEM,
tools: TOOLS,
messages,
});
messages.push({ role: 'assistant', content: res.content });
if (res.stop_reason !== 'tool_use') {
const text = res.content
.filter(b => b.type === 'text')
.map(b => b.text)
.join('')
.trim();
return { text, messages };
}
const results = [];
for (const block of res.content) {
if (block.type !== 'tool_use') continue;
let payload;
try {
payload = await HANDLERS[block.name](block.input);
} catch (err) {
console.error('[tool]', block.name, err);
payload = { error: 'lookup failed' };
}
results.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(payload),
});
}
messages.push({ role: 'user', content: results });
}
return { text: 'Let me check that and come back to you.', messages };
}
Four things that will bite you:
tool_use blocks and your next request has a tool_result referencing an id the API has never seen, and it fails with a 400.tool_use needs a matching tool_result in the very next message. Claude can call two tools in one turn. Answer both.The messages array that comes back out of the loop includes the tool calls and their results. Store that as your history, not just the final text. If you throw the intermediate turns away, Claude re-checks the same calendar slot every single message.
Everything above is a client-side tool: you host the function, you run it. The Model Context Protocol is the other shape, a standard so that a server someone else wrote, such as your calendar or CRM or database, exposes its tools and Claude can use them without you writing a handler for each.
For a WhatsApp agent talking to your own systems, plain tools are less machinery and easier to debug. Reach for MCP when you want to plug in a tool server you did not write, or when you are reusing the same tools across several agents.
The bot works. It still reads like a bot. Four fixes, none of them AI.
Claude writes Markdown by default. WhatsApp does not speak it. A Markdown double asterisk shows up as literal asterisks, and a heading hash shows up as a hash.
| Effect | Markdown (wrong here) | |
|---|---|---|
| Bold | *text* | **text** |
| Italic | _text_ | *text* |
| Strikethrough | ~text~ | ~~text~~ |
| Monospace | ```text``` | `text` |
Drag the table sideways to see every column.
Handle it in the system prompt first, with the plain-text rule from section 4.1, and then sanitize anyway, because the instruction leaks about one time in fifty.
function toWhatsApp(text) {
return text
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/\*\*(.+?)\*\*/g, '*$1*') // bold
.replace(/^\s*[-*]\s+/gm, '- ') // normalize bullets
.trim();
}
Use a hyphen and a space for bullets, never the bullet character. It renders inconsistently across clients and copies badly.
Silence between the customer's message and the reply reads as "nobody is there." Mark as read and start the typing bubble in one call, immediately, before you call Claude.
async function markReadAndType(phoneNumberId, messageId) {
await fetch(`https://graph.facebook.com/${GRAPH}/${phoneNumberId}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
status: 'read',
message_id: messageId,
typing_indicator: { type: 'text' },
}),
}).catch(err => console.error('[wa] read/typing', err));
}
The indicator clears when you send your reply, or after about 25 seconds, whichever comes first. It is not automatic; nothing shows unless you make this call. Fire it without letting it delay the model call, and if it fails, that is cosmetic rather than fatal.
A nine-hundred-character block is a wall. Humans send two or three short messages. Split on paragraph breaks and send in order, with a small pause so they arrive in the right sequence.
async function sendReply(phoneNumberId, to, text) {
const parts = toWhatsApp(text)
.split(/\n{2,}/)
.map(s => s.trim())
.filter(Boolean);
for (const part of parts.slice(0, 3)) { // never more than 3
await sendText(phoneNumberId, to, part);
await new Promise(r => setTimeout(r, 700));
}
}
Cap it at three. Without a cap, one runaway generation machine-guns eleven messages at a customer and looks like spam, which is also how numbers get reported.
People send "hi", "good morning", "I wanted to book" as three separate messages in four seconds. Answer each one and you fire three overlapping Claude calls that each see a different partial history, and you send three replies to what was one thought.
const pending = new Map();
function enqueue(msg, flush) {
const prev = pending.get(msg.from);
if (prev) clearTimeout(prev.timer);
const texts = prev ? [...prev.texts, msg.text] : [msg.text];
const timer = setTimeout(() => {
pending.delete(msg.from);
flush({ ...msg, text: texts.join('\n') });
}, 2500);
pending.set(msg.from, { texts, timer });
}
Two and a half seconds is a good starting point. This single change does more for how human the bot feels than any prompt tuning.
Real numbers, priced on September 6, 2026. There are two separate bills: Anthropic for the thinking, Meta for the delivery.
| Model | Input (per MTok) | Output (per MTok) |
|---|---|---|
| Claude Opus 5 | US$ 5 | US$ 25 |
| Claude Sonnet 5 | US$ 2 | US$ 10 |
| Claude Haiku 4.5 | US$ 1 | US$ 5 |
Drag the table sideways to see every column.
The trap is that the history replays on every turn. Turn eight pays for turns one through seven again. Cost per conversation is quadratic in its length, not linear.
Model one realistic conversation: eight customer messages, eight replies, a system prompt of about 400 tokens, roughly 30 tokens per customer message and 80 per reply. Input accumulates as 430, 540, 650, 760, 870, 980, 1,090 and 1,200, for 6,520 input tokens. Output is eight times 80, or 640 output tokens.
| Model | Per conversation | 100 conv/month | 1,000 conv/month |
|---|---|---|---|
| Claude Haiku 4.5 | US$ 0.0097 | US$ 0.97 | US$ 9.72 |
| Claude Sonnet 5 | US$ 0.0194 | US$ 1.94 | US$ 19.44 |
| Claude Opus 5 | US$ 0.0486 | US$ 4.86 | US$ 48.60 |
Drag the table sideways to see every column.
At 5.12 Brazilian reais to the dollar, the Banco Central rate on 4 September 2026, Claude Sonnet 5 is about ten centavos per conversation and about one hundred reais a month at a thousand conversations.
Since July 2025 Meta bills per message rather than per conversation, and service messages are free: when the customer writes first, everything you send back inside the 24-hour window costs nothing. A reactive support or booking agent, the thing this guide builds, pays Meta nothing at all.
You start paying when you open the conversation, with a template, outside the window. Brazilian rates run roughly four to five centavos per utility message and thirty-one to thirty-eight centavos per marketing message.
Those Brazilian per-message figures come from resellers rather than Meta's own published price list. Verify them against your account's rate card before you budget a campaign on them.
Not conversation volume. Two other things.
A knowledge base in the system prompt. Paste five thousand tokens of business context and every turn carries it. The eight-turn conversation above goes from 6,520 input tokens to 46,520, and the cost goes from about two cents to about nine, nearly five times as much for the same conversation. The fix is prompt caching: cache reads bill at a tenth of the input rate, so that same knowledge base drops back to well under a cent per conversation. There is a minimum cacheable length, so caching does nothing for a short system prompt. It is specifically the fix for a large, stable one.
Long threads. The twenty-turn cap from section 4.2 is a cost control as much as a context-window control. Uncapped, a chatty customer who never leaves generates a conversation that costs more every single message, forever.
A hundred reais a month for a thousand conversations is the API bill and nothing else. It does not include your server, your time building it, or your time on the Tuesday it breaks. That is section 10.
Everything here has taken down a real bot.
You can only send free-form messages within 24 hours of the customer's last message. One second past it, sending returns error 131047 and the customer gets nothing.
Outside the window you may send only a pre-approved template. Templates are submitted in the Meta console, reviewed, and categorized as marketing, utility or authentication. The category sets the price, and marketing is the expensive one.
The practical consequence is that your follow-up feature cannot be written as "message them tomorrow". It has to be "send an approved template tomorrow, and when they reply the window reopens and normal conversation resumes". Design it in from the start or you rewrite it later.
New numbers start capped at a low number of unique recipients per rolling 24 hours. The cap rises automatically with volume and a good quality rating, and it falls when customers block or report you.
Quality is visible in the WhatsApp Manager as green, yellow or red, and red leads to restriction. What pushes it down is unsolicited messaging and unanswered customers, both of which an over-eager bot produces. Watch it weekly, because by the time you notice through symptoms you are already restricted.
.env expires overnight and every send returns 401. Use the System User token.The dedupe in section 3.5 is not paranoia. Meta retries on non-2xx responses and on timeouts, and can deliver the same event more than once on its own. Without an atomic claim on the message id you will send double replies, and it will be intermittent and unreproducible locally. If you run more than one instance, an in-memory Set is not a dedupe.
Non-negotiable, and it is not a technical problem. Every bot needs an exit. Detect frustration or an explicit request and hand off: stop auto-replying on that thread, flag it, notify a person.
const HUMAN = /\b(atendente|humano|pessoa real|human|agent|representative)\b/i;
if (HUMAN.test(msg.text)) {
await flagForHuman(msg.from);
await sendText(msg.phoneNumberId, msg.from,
'Sure, I am getting a person to you now.');
return; // and stay quiet on this thread
}
A bot that will not let go is worse than no bot. It is also, in several jurisdictions, a compliance problem.
Disclose it. Meta's business policies expect it, some jurisdictions require it, and customers work out the answer within two messages anyway. A single line in the first reply costs nothing and prevents the angry review that starts "I thought I was talking to a person".
One file, no framework beyond Express. Everything from sections 3 through 6.
whatsapp-claude/
index.js
package.json
.env
The manifest:
{
"name": "whatsapp-claude",
"type": "module",
"scripts": { "start": "node index.js" },
"dependencies": {
"@anthropic-ai/sdk": "^0.70.0",
"express": "^4.21.0"
}
}
The environment:
ANTHROPIC_API_KEY=sk-ant-...
WA_TOKEN=EAAG... # System User token, never expires
APP_SECRET=... # App Dashboard > Settings > Basic
VERIFY_TOKEN=any-string-you-invent
PHONE_NUMBER_ID=... # WhatsApp > API Setup
PORT=3000
And the whole server:
import express from 'express';
import crypto from 'crypto';
import Anthropic from '@anthropic-ai/sdk';
const GRAPH = 'v26.0';
const MODEL = 'claude-sonnet-5';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const SYSTEM = `You are the assistant for [BUSINESS NAME].
You answer customers on WhatsApp. You are an AI assistant; say so if asked.
Rules:
- Reply in the customer's language.
- Two or three sentences maximum. This is a chat, not an email.
- Plain text only. No markdown, no headings, no bullet characters.
- Never invent prices, hours, or availability. Call a tool, or say you will check.`;
const TOOLS = [{
name: 'check_availability',
description:
'Check open appointment slots for a date. Use whenever the customer asks ' +
'about scheduling or availability. Never guess without calling this.',
input_schema: {
type: 'object',
properties: { date: { type: 'string', description: 'ISO date YYYY-MM-DD' } },
required: ['date'],
},
}];
const HANDLERS = {
check_availability: async ({ date }) => ({ date, slots: ['09:00', '14:00', '16:30'] }),
};
/* state: replace all three with a real store in production */
const threads = new Map(); // phone -> messages[]
const seen = new Set(); // wamid
const pending = new Map(); // phone -> debounce buffer
const getHistory = from => threads.get(from) || [];
const setHistory = (from, m) => threads.set(from, m.slice(-40));
const claim = id => (seen.has(id) ? false : (seen.add(id), true));
/* whatsapp */
async function wa(body) {
const r = await fetch(
`https://graph.facebook.com/${GRAPH}/${process.env.PHONE_NUMBER_ID}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WA_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ messaging_product: 'whatsapp', ...body }),
},
);
if (!r.ok) console.error('[wa]', r.status, await r.text());
return r.ok;
}
const sendText = (to, body) =>
wa({ recipient_type: 'individual', to, type: 'text', text: { preview_url: false, body } });
const markReadAndType = messageId =>
wa({ status: 'read', message_id: messageId, typing_indicator: { type: 'text' } })
.catch(() => {});
function toWhatsApp(text) {
return text
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '*$1*')
.replace(/^\s*[-*]\s+/gm, '- ')
.trim();
}
async function sendReply(to, text) {
const parts = toWhatsApp(text).split(/\n{2,}/).map(s => s.trim()).filter(Boolean);
for (const part of parts.slice(0, 3)) {
await sendText(to, part);
await new Promise(r => setTimeout(r, 700));
}
}
/* claude */
async function askClaude(history) {
const messages = [...history];
for (let hop = 0; hop < 5; hop++) {
const res = await anthropic.messages.create({
model: MODEL, max_tokens: 400, system: SYSTEM, tools: TOOLS, messages,
});
messages.push({ role: 'assistant', content: res.content });
if (res.stop_reason !== 'tool_use') {
const text = res.content.filter(b => b.type === 'text').map(b => b.text).join('').trim();
return { text, messages };
}
const results = [];
for (const block of res.content) {
if (block.type !== 'tool_use') continue;
let payload;
try {
payload = await HANDLERS[block.name](block.input);
} catch (err) {
console.error('[tool]', block.name, err);
payload = { error: 'lookup failed' };
}
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(payload) });
}
messages.push({ role: 'user', content: results });
}
return { text: 'Let me check that and come back to you.', messages };
}
/* pipeline */
const HUMAN = /\b(atendente|humano|pessoa real|human|agent|representative)\b/i;
async function respond(from, text) {
if (HUMAN.test(text)) {
await flagForHuman(from);
return sendText(from, 'Sure, I am getting a person to you now.');
}
const history = [...getHistory(from), { role: 'user', content: text }];
try {
const { text: reply, messages } = await askClaude(history);
setHistory(from, messages);
await sendReply(from, reply);
} catch (err) {
console.error('[claude]', err);
await sendText(from, 'Sorry, I had a problem here. Can you send that again?');
}
}
async function flagForHuman(from) {
console.warn('[handoff]', from); // wire to your inbox / CRM / alert
}
function enqueue(from, text) {
const prev = pending.get(from);
if (prev) clearTimeout(prev.timer);
const texts = prev ? [...prev.texts, text] : [text];
const timer = setTimeout(() => {
pending.delete(from);
respond(from, texts.join('\n')).catch(e => console.error('[respond]', e));
}, 2500);
pending.set(from, { texts, timer });
}
function extractMessages(body) {
const out = [];
for (const entry of body.entry || []) {
for (const change of entry.changes || []) {
const value = change.value || {};
if (!value.messages) continue;
for (const msg of value.messages) {
if (msg.type !== 'text') continue;
out.push({ id: msg.id, from: msg.from, text: msg.text.body });
}
}
}
return out;
}
/* server */
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function verifySignature(req) {
const header = req.get('X-Hub-Signature-256') || '';
const expected = header.startsWith('sha256=') ? header.slice(7) : header;
if (!expected) return false;
const actual = crypto
.createHmac('sha256', process.env.APP_SECRET)
.update(req.rawBody)
.digest('hex');
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
}
app.get('/webhook', (req, res) => {
if (req.query['hub.mode'] === 'subscribe' &&
req.query['hub.verify_token'] === process.env.VERIFY_TOKEN) {
return res.status(200).type('text/plain').send(req.query['hub.challenge']);
}
res.sendStatus(403);
});
app.post('/webhook', (req, res) => {
if (!verifySignature(req)) return res.sendStatus(401);
res.sendStatus(200); // ack FIRST
for (const msg of extractMessages(req.body)) {
if (!claim(msg.id)) continue;
markReadAndType(msg.id);
enqueue(msg.from, msg.text);
}
});
app.listen(process.env.PORT || 3000, () => console.log('up'));
Before this goes anywhere real, replace three things. threads, seen and pending are in-memory Maps. They vanish on restart and they do not work across two instances. Move them to Redis or Postgres. Everything else in the file is production shape.
Run it:
npm install && node index.js
npx ngrok http 3000
Register the public URL as described in section 2.8, subscribe to the messages field, and message your test number.
Everything above is real and it works. You can run it yourself.
Read back what running it actually means, though. Business verification. Template approval, and resubmission when a category is rejected. A quality rating you have to watch weekly. Redis, because in-memory state breaks the moment you scale past one box. A knowledge base that goes stale the day prices change. Prompt caching once the bill grows. A handoff path to a human that has to reach an actual person. And the Tuesday something in that chain breaks while customers are messaging.
The code is a weekend. The operations are forever.
That is the whole trade. If you enjoy running it, run it. This guide is complete on purpose, and nothing in it is held back.
If you would rather the thing just worked, that is what Certu does: the WhatsApp agent, the business knowledge base, the CRM, the human handoff and the number, all of it operated for you. Certu serves small and medium businesses in Brazil, from 199 reais a month.
See Certu plansNot on its own. Claude has no connection to WhatsApp; it answers HTTP requests. You run a small server in the middle that receives Meta webhooks, calls the Claude Messages API with the conversation history, and posts the reply back to the Graph API. That server is about 120 lines and the whole of it is in section 9 of this guide.
Both work, and sections 3 to 6 of this guide apply unchanged to either. QR libraries such as Baileys or whatsapp-web.js drive a logged-in WhatsApp Web session, so you can send your first message in about ten minutes with no verification. They also drop sessions, need re-pairing, and can get the number banned at Meta discretion. Use QR to prototype and the Cloud API for a number a business actually depends on.
On a typical eight-turn conversation with a short system prompt, about US$ 0.019 on Claude Sonnet 5, US$ 0.0097 on Haiku 4.5 and US$ 0.049 on Opus 5, at prices published in September 2026. A thousand conversations a month on Sonnet 5 is roughly US$ 19, about R$ 100. Meta charges nothing for replies sent inside the 24-hour service window that opens when the customer writes first, so a purely reactive agent pays Meta zero.
Because you are holding the webhook connection open while you call Claude. Meta expects a fast 2xx and retries the delivery when it does not get one, so your handler runs again on the same message. Answer 200 immediately and do the AI work afterwards, and deduplicate on the message id with an atomic claim. An in-memory Set stops working the moment you run two instances.
The Messages API is stateless. Every call has to carry the whole conversation or the model starts from nothing. Store the history yourself, keyed by phone number rather than by message id, trim it to roughly the last 20 turns so cost does not grow forever, and expire it after about 24 hours so a customer returning weeks later starts fresh.
Claude writes Markdown by default and WhatsApp does not read Markdown. WhatsApp uses single asterisks for bold, underscores for italic and tildes for strikethrough, so a Markdown double asterisk renders as literal characters. Ask for plain text in the system prompt and sanitize the output anyway, because the instruction leaks occasionally.
Yes, through tool use. You declare functions with a name, a description and an input schema, Claude answers with a tool_use block when it wants one, you run the code and send the result back as a tool_result. The description is what decides whether Claude calls the tool, so write it as an instruction rather than a docstring, and always cap the number of tool hops so a stubborn loop cannot run up a bill.
The 24-hour window. Free-form messages are only allowed within 24 hours of the customer last writing to you; past that, sending returns error 131047 and nothing is delivered. Outside the window you may only send a template that Meta has already approved, and its category, marketing or utility or authentication, sets what it costs.