Joan Comadran
Field notes

Engineering

Building a WhatsApp bot with Claude and the Meta Cloud API

The happy path is 20 lines. Then Meta retries your webhook, Vercel kills the function mid-reply, and your lead gets the same message twice. Here's the version that survives production.

July 20, 20267 min read

A WhatsApp bot that answers and qualifies leads with Claude is, on the happy path, embarrassingly short: Meta's Cloud API POSTs an incoming message to your webhook, you send the text to Claude, you POST the reply back to the Graph API. Twenty lines. You can demo it in an afternoon and feel like a genius.

Then you put it in front of real numbers, and it turns out the twenty lines were the easy 80%. This is a post about the other 80%: the four things that broke the bot I run in production, and what each one actually required. It's a supporting piece under the weekend AI-product stack, zoomed all the way into one product.

The shape of the happy path

So we're on the same page, here's the version that works in the demo. One webhook route, Node runtime because the Anthropic SDK doesn't run on Edge:

ts
// app/api/webhook/route.ts
export const runtime = "nodejs";

export async function POST(req: Request) {
  const body = await req.json();
  const msg = body.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
  if (!msg) return new Response("ok"); // status update, not a message

  const reply = await answerWithClaude(msg.from, msg.text.body);
  await sendWhatsApp(msg.from, reply);
  return new Response("ok");
}

Meta also does a one-time GET handshake to verify the webhook (echo back hub.challenge if the token matches), and every real POST is signed with your app secret so you can reject forgeries. Both are boring and both are non-negotiable. Neither is where the interesting failures live.

Problem 1: Meta retries, Vercel gives up, the lead gets two messages

Here's the one that cost me the most sleep, and the one no tutorial mentions.

Meta expects a 200 from your webhook fast. If it doesn't get one, it assumes delivery failed and retries the same message. Reasonable. Meanwhile your webhook is on a serverless function with a hard execution ceiling (60 seconds on Vercel), and a single incoming message can trigger several Claude calls: fold the conversation into context, run the reply, maybe loop through a couple of tool calls to book an appointment, then run a separate qualification pass. Stack up the worst case and you sail past 60 seconds.

So the platform kills the function mid-flight. The catch is when it dies: after you've already sent the reply to the lead, but before you've marked the message as handled. Meta sees no 200, retries, and your bot cheerfully answers the same "hola" a second time. Users notice. It looks broken because it is.

Two things fix it, and neither is glamorous.

First, put the SDK client on a leash. The default Anthropic client waits up to ten minutes and retries twice, which is a lovely default for a background job and a loaded gun inside a 60-second function:

ts
import Anthropic from "@anthropic-ai/sdk";

// 15s per call, one retry. Fits inside the webhook's budget.
const anthropic = new Anthropic({ timeout: 15_000, maxRetries: 1 });

Second, give the function an explicit time budget and let it refuse to start work it can't finish. The rule is: stop beginning new Claude calls with enough margin left to close cleanly (release locks, persist state, return 200) instead of getting shot halfway:

ts
const DEADLINE = Date.now() + (60 - 8) * 1000; // 8s of headroom under the ceiling

function roomForAnotherCall(marginMs = 20_000) {
  return Date.now() + marginMs <= DEADLINE;
}

The tool-use loop checks roomForAnotherCall() before each round and bails to a safe fallback reply if the margin's gone. Skipping the qualification pass this turn is fine, it runs again on the lead's next message. A duplicated message to a prospect is not fine. Losing a turn is cheap; looking broken is expensive.

And to actually close the retry hole, incoming messages get claimed before processing and resolved after, with a cap on attempts. If a retry arrives for a message that's already claimed, it's dropped. This is just idempotency, but it's the difference between "handles Meta's retries" and "amplifies them".

Problem 2: the 24-hour window will humble your marketing ideas

You cannot message a WhatsApp user whenever you feel like it. Meta lets you send free-form text only within 24 hours of the user's last message. Outside that window, you're restricted to pre-approved message templates. No exceptions, no clever workarounds.

This quietly kills a lot of "the bot will follow up in three days" plans. Any re-engagement outside the window has to go through a template you submitted and Meta approved, and the send call fails with a 400 if you try to sneak free text through. So the code treats a failed send as a first-class event and logs Meta's actual error detail, because "message not sent" has about six causes (expired token, closed window, malformed number) and you will want to know which.

One more small tax: WhatsApp's text formatting isn't Markdown. Bold is a single asterisk (*bold*), not two. Claude writes Markdown by default, so **bold** arrives with a stray asterisk hanging off it. A two-line transform on the way out earns its place:

ts
function toWhatsApp(text: string): string {
  return text.replace(/\*\*(.+?)\*\*/g, "*$1*");
}

There's also a hard 4096-character limit per message. Claude with a sane max_tokens won't hit it, but the send path truncates rather than fails, because a slightly clipped reply beats no reply.

Problem 3: the model is where the money leaks

The bot is multi-tenant, and inference is the dominant cost, so model choice is a business decision I refused to leave to a config file someone could fat-finger.

The rule: on the shared API key I pay for, everyone gets Haiku. claude-haiku-4-5 is the cheap, fast tier ($1 and $5 per million tokens), and for "answer questions and book a slot" it's completely fine. If a tenant wants a stronger model, they bring their own Anthropic key (BYOK), and then, and only then, do we respect whatever model they picked:

ts
const BASE_MODEL = "claude-haiku-4-5";

function effectiveModel(opts: { apiKey?: string; model?: string }): string {
  if (!opts.apiKey) return BASE_MODEL; // on our key, Haiku, always
  return opts.model ?? BASE_MODEL; // their key, their bill, their choice
}

The important detail is that this gate is applied at every call site that hits the API, not just the main reply: the qualification pass and the cost accounting use the same function. A cost control that one code path can bypass isn't a cost control, it's a suggestion.

There's a related bit of defensiveness worth stealing. If a tenant's own key turns out to be revoked or unpermissioned mid-conversation, the bot degrades that call to the global key instead of leaving the lead staring at silence. But it only does this for auth errors, not for 429 or 5xx, because those are transient and the right move there is to retry with the same key, not to quietly start spending my money on their traffic.

Problem 4: the tool-use loop needs an off switch

Booking appointments means giving Claude tools (check availability, create a booking) and running the standard agentic loop: call the model, if it wants a tool you run it and feed the result back, repeat until it produces a final text reply.

The failure mode is obvious once you've seen it: a model that keeps calling tools in a loop will happily eat your entire time budget and then get killed, which lands you right back in Problem 1. So the loop is bounded twice. A hard iteration cap (five rounds; appointments need one or two), and the deadline check from earlier before each round:

ts
let response = await anthropic.messages.create({ model, tools, messages, max_tokens: 400 });

for (let i = 0; i < 5 && response.stop_reason === "tool_use"; i++) {
  if (!roomForAnotherCall()) break; // out of time: bail to a safe reply

  const results = await runToolCalls(response.content);
  messages.push({ role: "assistant", content: response.content });
  messages.push({ role: "user", content: results });
  response = await anthropic.messages.create({ model, tools, messages, max_tokens: 400 });
}

Two independent limits for two independent failures: the iteration cap stops a model stuck in a loop, the deadline stops a slow-but-progressing loop from getting guillotined. Belt and suspenders, because production is where you find out you needed both.

The takeaway

The gap between the demo and the deployed bot isn't Claude, and it isn't the Cloud API. Both are pleasant to work with. The gap is the seam between an event source that retries (Meta) and a compute model that quits (serverless), and every hard-won line above lives in that seam. If you're wiring up your first WhatsApp bot, budget your time for the seam, not the sunny path. The sunny path really is twenty lines.

If you want the layer above this one, the weekend AI-product stack covers where this bot sits in the bigger picture, and there's a separate post coming on keeping the token bill from ruining your month, which is the natural sequel to Problem 3.

Keep reading

02