Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build a WhatsApp AI Agent

A WhatsApp AI agent is a webhook: Meta's Cloud API posts incoming messages to your server, and you reply through the same API within a 24-hour messaging window. This guide builds one with FastAPI and Claude that qualifies real estate leads, answers listing questions, books a showing, and escalates serious buyers to a human agent. Budget 60-75 minutes and a Meta developer account to test it.

What you'll learn

  • Wire up the WhatsApp Cloud API's webhook verification handshake and incoming-message payload
  • Keep a per-sender conversation history so Claude remembers what a lead already told you
  • Give the agent tools to check listing availability, book a showing, and escalate a hot lead to a human
  • Reply through the Cloud API's /messages endpoint instead of a chat SDK
  • Verify webhook payloads actually came from Meta before trusting anything in them
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate11 min read60-75 minPython 3.10+, FastAPI, a Meta developer account with WhatsApp Cloud API access, Claude API keyLast updated November 2026RapidDev Engineering Team
TL;DR

A WhatsApp AI agent is a webhook: Meta's Cloud API posts incoming messages to your server, and you reply through the same API within a 24-hour messaging window. This guide builds one with FastAPI and Claude that qualifies real estate leads, answers listing questions, books a showing, and escalates serious buyers to a human agent. Budget 60-75 minutes and a Meta developer account to test it.

Quick facts about this guide
FactValue
ToolWhatsApp Cloud API
DifficultyIntermediate
Time required60-75 min
CompatibilityPython 3.10+, FastAPI, a Meta developer account with WhatsApp Cloud API access, Claude API key
Last updatedNovember 2026

What you'll build

You'll build a WhatsApp agent for a real estate brokerage that talks to inbound leads: it answers questions about a listing, checks whether a showing slot is open, books it, and the moment a lead sounds like a serious, ready-to-buy prospect, hands them off to a human agent instead of trying to close anything itself.

Unlike a chat widget or an SDK-based agent, a WhatsApp agent has no persistent connection to maintain. Meta's Cloud API posts each incoming message to a webhook URL you host, and you reply by making your own POST request back to a different endpoint on the same API, inside a 24-hour window from the user's last message. There's no streaming session to keep alive; the whole architecture is just two HTTP endpoints and whatever state you keep between messages yourself.

That statelessness is also the main engineering problem: nothing about WhatsApp tracks the conversation for you. You keep the message history per sender, feed it to Claude along with the leasing tools, and send the reply back through the API, all inside a single webhook handler that Meta expects to get a fast 200 response from regardless of how long the actual reasoning takes.

Prerequisites

  • A Meta developer account with a WhatsApp Business app, a test phone number, and a permanent access token
  • A publicly reachable HTTPS URL for your webhook (a tunnel like ngrok is fine for testing)
  • An Anthropic API key (ANTHROPIC_API_KEY) with available credits
  • pip install fastapi uvicorn httpx anthropic

Step-by-step guide

1

Register your webhook and verify it

In Meta's developer console, point your WhatsApp app's webhook at your server's URL and choose a verify token, any string you make up yourself. Meta calls your endpoint once with a GET request containing that token and a challenge value; your server must echo the challenge back exactly, or the webhook registration fails before you ever receive a real message.

typescript
1VERIFY_TOKEN = "a-secret-you-choose"
2
3@app.get("/webhook")
4async def verify(request: Request):
5 params = request.query_params
6 if params.get("hub.verify_token") == VERIFY_TOKEN:
7 return int(params["hub.challenge"])
8 return Response(status_code=403)

Expected result: Meta's console shows the webhook as verified, with a green check next to the callback URL.

2

Parse the incoming message payload

A real incoming text message arrives as a POST with a nested structure: entry, then changes, then a value object containing a messages array. Pull the sender's WhatsApp ID and the message text out of that structure, and return a 200 immediately regardless of what you do next; Meta expects a fast acknowledgment and will retry delivery if your webhook is slow to respond.

typescript
1@app.post("/webhook")
2async def receive(request: Request):
3 body = await request.json()
4 entry = body["entry"][0]["changes"][0]["value"]
5 if "messages" not in entry:
6 return {"status": "ignored"}
7 msg = entry["messages"][0]
8 sender, text = msg["from"], msg["text"]["body"]
9 # hand off to the agent, see next step
10 return {"status": "received"}

Expected result: Sending yourself a test WhatsApp message to the test number logs the correct sender ID and message text.

3

Keep per-sender conversation history

WhatsApp's API has no concept of a session; every incoming message is just a POST with no memory attached. Keep a dict keyed by the sender's WhatsApp ID holding that lead's message history, and swap it for Redis or a database before going to production, since an in-memory dict resets on every deploy and doesn't survive multiple server instances.

typescript
1CONVERSATIONS: dict[str, list[dict]] = {}
2
3def get_history(sender: str) -> list[dict]:
4 return CONVERSATIONS.setdefault(sender, [])

Expected result: A dict that grows one entry per unique lead, each holding that lead's full message history so far.

4

Call Claude with the leasing tools

Append the incoming message to that sender's history, call the Messages API with check_availability, book_showing, and escalate_to_human defined as tools, and process any tool_use blocks the same way you would in any other agent. The system prompt sets the one hard rule: escalate instead of guessing the moment a lead sounds ready to make an offer.

typescript
1SYSTEM_PROMPT = (
2 "You qualify real estate leads over WhatsApp for a 123 Maple listing. "
3 "Answer questions about the listing, check availability before offering a "
4 "showing time, and book it once confirmed. If the lead sounds ready to make "
5 "an offer, or asks anything about price negotiation, call escalate_to_human."
6)
7
8def ask_claude(sender: str, text: str) -> str:
9 history = get_history(sender)
10 history.append({"role": "user", "content": text})
11 response = client.messages.create(
12 model="claude-sonnet-5-5", max_tokens=1024,
13 system=SYSTEM_PROMPT, tools=TOOLS, messages=history,
14 )
15 history.append({"role": "assistant", "content": response.content})
16 return handle_response(sender, response)

Expected result: A function that returns the final text reply after resolving any tool calls, ready to send back to WhatsApp.

5

Send the reply back through the Cloud API

Reply by making a separate POST request to the Cloud API's /messages endpoint for that phone number, with the recipient's WhatsApp ID and your reply text in the body, authenticated with your access token as a bearer token. This is a completely different call than receiving the message; there's no 'respond' field on the webhook itself.

typescript
1import httpx
2
3GRAPH_URL = f"https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages"
4
5async def send_whatsapp_message(to: str, body: str):
6 async with httpx.AsyncClient() as http:
7 await http.post(GRAPH_URL, headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
8 json={"messaging_product": "whatsapp", "to": to,
9 "type": "text", "text": {"body": body}})

Expected result: The reply shows up in the test WhatsApp conversation within a second or two of your webhook processing the message.

6

Wire up escalation to a real human

When escalate_to_human fires, don't just tell the lead someone will follow up and stop there; actually notify a human, through a Slack webhook, an email, or a CRM task, with the lead's phone number and the reason for the handoff included. The whole point of this tool is that a hot lead gets a real human's attention quickly, not that the bot politely ends the conversation.

typescript
1async def escalate(sender: str, reason: str):
2 # In production: POST to a Slack webhook or create a CRM task here.
3 print(f"[ESCALATION] {sender}: {reason}")

Expected result: Asking the test number something like 'I want to make an offer today' triggers a logged (or posted) escalation, not a vague bot reply.

7

Verify webhook signatures before trusting anything

Meta signs every webhook POST with an X-Hub-Signature-256 header, an HMAC over the raw request body using your app secret. Check it before parsing anything from the payload; without this, anyone who finds your webhook URL can POST a fake message and get your agent to act on it.

typescript
1import hashlib, hmac
2
3def verify_signature(raw_body: bytes, signature_header: str, app_secret: str) -> bool:
4 expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest()
5 return hmac.compare_digest(f"sha256={expected}", signature_header)

Expected result: A function that rejects any webhook POST whose signature doesn't match, before the payload is parsed at all.

Complete working example

whatsapp_lead_agent.py
1"""WhatsApp lead-qualification agent for a real estate listing.
2Run: uvicorn whatsapp_lead_agent:app --reload
3pip install fastapi uvicorn httpx anthropic
4"""
5import hashlib
6import hmac
7import os
8
9import anthropic
10import httpx
11from fastapi import FastAPI, Request, Response
12
13VERIFY_TOKEN = os.environ["WHATSAPP_VERIFY_TOKEN"]
14ACCESS_TOKEN = os.environ["WHATSAPP_ACCESS_TOKEN"]
15PHONE_NUMBER_ID = os.environ["WHATSAPP_PHONE_NUMBER_ID"]
16APP_SECRET = os.environ["WHATSAPP_APP_SECRET"]
17GRAPH_URL = f"https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages"
18
19app = FastAPI()
20client = anthropic.Anthropic()
21CONVERSATIONS: dict[str, list[dict]] = {}
22
23SYSTEM_PROMPT = (
24 "You qualify real estate leads over WhatsApp for the 123 Maple listing. "
25 "Answer questions about the listing, check availability before offering a "
26 "showing time, and book it once confirmed. If the lead sounds ready to "
27 "make an offer, or asks anything about price negotiation, call "
28 "escalate_to_human instead of handling it yourself."
29)
30
31TOOLS = [
32 {"name": "check_availability", "description": "Check open showing slots for the listing.",
33 "input_schema": {"type": "object", "properties": {"date": {"type": "string"}}, "required": ["date"]}},
34 {"name": "book_showing", "description": "Book a confirmed showing slot.",
35 "input_schema": {"type": "object", "properties": {
36 "date": {"type": "string"}, "time": {"type": "string"}, "name": {"type": "string"}},
37 "required": ["date", "time", "name"]}},
38 {"name": "escalate_to_human", "description": "Hand off a hot lead to a human agent.",
39 "input_schema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}},
40]
41
42
43def verify_signature(raw_body: bytes, signature_header: str) -> bool:
44 expected = hmac.new(APP_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
45 return hmac.compare_digest(f"sha256={expected}", signature_header or "")
46
47
48def get_history(sender: str) -> list[dict]:
49 return CONVERSATIONS.setdefault(sender, [])
50
51
52async def escalate(sender: str, reason: str) -> None:
53 # In production: POST to a Slack webhook or create a CRM task here.
54 print(f"[ESCALATION] {sender}: {reason}")
55
56
57def run_tool(name: str, tool_input: dict) -> dict:
58 if name == "check_availability":
59 return {"content": [{"type": "text", "text": "2:00 PM and 4:30 PM are open."}]}
60 if name == "book_showing":
61 return {"content": [{"type": "text", "text": f"Booked {tool_input['date']} at {tool_input['time']}."}]}
62 if name == "escalate_to_human":
63 return {"content": [{"type": "text", "text": "A human agent will follow up shortly."}]}
64 return {"content": [{"type": "text", "text": "Unknown tool."}], "is_error": True}
65
66
67async def ask_claude(sender: str) -> str:
68 history = get_history(sender)
69 response = client.messages.create(
70 model="claude-sonnet-5-5", max_tokens=1024,
71 system=SYSTEM_PROMPT, tools=TOOLS, messages=history,
72 )
73 history.append({"role": "assistant", "content": response.content})
74
75 tool_blocks = [b for b in response.content if b.type == "tool_use"]
76 if not tool_blocks:
77 return "".join(b.text for b in response.content if b.type == "text")
78
79 results = []
80 for block in tool_blocks:
81 if block.name == "escalate_to_human":
82 await escalate(sender, block.input.get("reason", ""))
83 result = run_tool(block.name, block.input)
84 results.append({"type": "tool_result", "tool_use_id": block.id, **result})
85 history.append({"role": "user", "content": results})
86
87 follow_up = client.messages.create(
88 model="claude-sonnet-5-5", max_tokens=1024,
89 system=SYSTEM_PROMPT, tools=TOOLS, messages=history,
90 )
91 history.append({"role": "assistant", "content": follow_up.content})
92 return "".join(b.text for b in follow_up.content if b.type == "text")
93
94
95async def send_whatsapp_message(to: str, body: str) -> None:
96 async with httpx.AsyncClient() as http:
97 await http.post(
98 GRAPH_URL,
99 headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
100 json={"messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": body}},
101 )
102
103
104@app.get("/webhook")
105async def verify(request: Request):
106 params = request.query_params
107 if params.get("hub.verify_token") == VERIFY_TOKEN:
108 return int(params["hub.challenge"])
109 return Response(status_code=403)
110
111
112@app.post("/webhook")
113async def receive(request: Request):
114 raw_body = await request.body()
115 if not verify_signature(raw_body, request.headers.get("X-Hub-Signature-256", "")):
116 return Response(status_code=403)
117
118 body = await request.json()
119 entry = body["entry"][0]["changes"][0]["value"]
120 if "messages" not in entry:
121 return {"status": "ignored"}
122
123 msg = entry["messages"][0]
124 sender, text = msg["from"], msg["text"]["body"]
125 get_history(sender).append({"role": "user", "content": text})
126
127 reply = await ask_claude(sender)
128 await send_whatsapp_message(sender, reply)
129 return {"status": "ok"}
130

Common mistakes

Why it's a problem: Forgetting that WhatsApp expects your webhook to respond quickly

How to avoid: Return a fast 200 and do slow work (LLM calls, tool execution) inside the request handler asynchronously, or hand it to a background task. A slow webhook response triggers Meta's retry logic and can duplicate messages.

Why it's a problem: Trusting the webhook payload without checking X-Hub-Signature-256

How to avoid: Verify the HMAC signature against your app secret before parsing anything. An unverified webhook URL can be POSTed to by anyone who finds it.

Why it's a problem: Keeping conversation history only in an in-memory dict in production

How to avoid: Move it to Redis or a database before going live. An in-memory dict loses every lead's history on every deploy and doesn't work across multiple server instances.

Why it's a problem: Trying to send a free-form reply more than 24 hours after the lead's last message

How to avoid: Use a pre-approved template message to re-engage outside the 24-hour window. A plain text reply outside that window will be rejected by the API.

Why it's a problem: Having the agent try to close or negotiate instead of escalating

How to avoid: Keep escalate_to_human in the system prompt's explicit scope boundary, and make sure it actually notifies a person, not just a logged print statement left in from testing.

Best practices

  • Verify every webhook's signature before parsing its body, no exceptions.
  • Acknowledge the webhook fast and do the real work asynchronously so Meta's retry logic doesn't fire.
  • Store per-sender conversation history somewhere durable and shared across server instances.
  • Make escalate_to_human actually reach a human, through Slack, email, or a CRM task, not just a log line.
  • Track the 24-hour messaging window per sender and fall back to a template message once it's expired.
  • Keep the system prompt's scope boundary explicit: what the agent can decide versus what it must hand off.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have this WhatsApp lead-qualification agent (below) with check_availability, book_showing, and escalate_to_human tools over the Cloud API. Add tracking for the 24-hour messaging window per sender, and fall back to a named WhatsApp template message when a reply would fall outside it. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open whatsapp_lead_agent.py and ask: 'Replace the in-memory CONVERSATIONS dict with Redis, keeping the same get_history interface so the rest of the file doesn't change.'

Frequently asked questions

Do I need the WhatsApp Business app, or does the Cloud API work differently?

This guide uses Meta's WhatsApp Cloud API, which is a REST API your server calls directly, separate from the consumer WhatsApp Business app. You don't need the app installed anywhere; you need a Meta developer account, a test or production phone number, and an access token.

What's the 24-hour messaging window, exactly?

You can send free-form replies to a user for 24 hours after their last message to you. After that window closes, you can only re-engage them with a pre-approved template message, not a plain text reply, until they message you again.

How do I test this before I have a production phone number?

Meta's developer console provides a free test phone number tied to your app. Use a tunnel like ngrok to expose your local FastAPI server's webhook URL, register that URL in the console, and message the test number directly from your own WhatsApp.

Why verify X-Hub-Signature-256 instead of just trusting the webhook?

Your webhook URL is a public HTTPS endpoint. Without signature verification, anyone who discovers it can POST a fake message payload and have your agent act on it, including triggering tools like book_showing with made-up data.

Can this agent send images or quick-reply buttons, not just text?

Yes, the Cloud API supports interactive messages, images, documents, and location sharing in addition to plain text; this guide builds the text-only path first since it covers the harder architectural pieces (webhooks, history, tools) that every message type shares.

Can someone build and host this WhatsApp agent for my business?

Yes. RapidDev builds production WhatsApp agents, including signature verification, durable conversation storage, and real escalation routing. A free 30-minute consultation is the fastest way to scope your version.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.