Your agent can read the calendar, open a pull request, and query production. Then someone asks it to text a customer, and it writes you a draft to copy-paste into your phone. WhatsApp is the last channel most agents cannot actually touch. The fix is smaller than it looks: one tool definition, one authenticated endpoint, and a number your contacts already have saved.
What does an AI agent actually need to use WhatsApp?
An agent needs four things to send on WhatsApp: a number it is permitted to send from, an authenticated HTTP endpoint, a tool definition describing that endpoint in your framework's schema format, and a return path for replies. Everything after that is production hardening.
The bare checklist:
- A sending number - one your recipients recognise, already connected
- An authenticated endpoint - HTTP with a bearer token, callable from your executor
- A tool definition - JSON Schema in Claude, OpenAI, or LangChain shape
- An inbound path - a signed webhook, so replies reach your agent
- Failure semantics - idempotency keys, retries, delivery status
Two very different products answer to the name "WhatsApp API," and picking the wrong one costs a sprint. Meta's WhatsApp Business Platform (the Cloud API) is the sanctioned enterprise route. It bills per message as of July 1, 2025, and Meta's own docs are explicit that rates vary by template category and recipient country code on the WhatsApp pricing page. It also requires a dedicated business number. Meta states plainly that registered numbers "cannot be used with WhatsApp Messenger" and that "numbers already in use with WhatsApp cannot be registered unless they are deleted first".
This article is about the other route, and it is worth saying up front: everything below runs on your own existing WhatsApp number, driven through WhatsApp Web or a managed cloud gateway. It is not the Meta Cloud API. No business verification, no template approval, no per-message fee, and the message arrives from the number people already text you on. The tradeoffs are real and I cover them in full further down. If you want the category argument rather than the implementation, WhatsApp going agent-native makes it.
Which WhatsApp interface should your agent call: hosted MCP or the /v1 REST API?
Use the hosted MCP server when the agent is a chat client you do not control the loop of, like Claude. Use the /v1 REST API when you own the executor and want a typed tool your own code invokes. Both hit the same engine and the same connected number, so the choice is about who runs the loop.

| Hosted MCP server | /v1 REST API | |
|---|---|---|
| Best for | Claude, Claude Code, any MCP client | Your own agent, LangChain, a cron job |
| Auth | OAuth 2.1 authorization code, PKCE S256 required | Authorization: Bearer bt_live_... |
| Transport | Streamable HTTP at https://api.blueticks.co/mcp | HTTPS at https://api.blueticks.co/v1 |
| Setup | One URL paste, no code | Mint a key, write the executor |
| Surface | 9 action-based tools | Full endpoint surface |
| Schema discovery | Automatic via tools/list | You write the tool definition |
Honesty first, because it matters here: Blueticks was not the first WhatsApp MCP server, and the open-source ones are good. lharries/whatsapp-mcp wraps the Go whatsmeow library, stores your history in local SQLite, and has earned roughly 5,950 GitHub stars. It runs entirely on your machine, which is the right answer if data residency is your hard constraint. Its last commit was July 13, 2025, so budget for maintenance. jlucaso1/whatsapp-mcp-ts does the same over Baileys in TypeScript.
What a hosted server changes is uptime and the loop around it. A local MCP server dies when your laptop sleeps. A managed gateway keeps the WhatsApp session alive server-side, survives restarts, and exposes the same tools over OAuth so claude.ai on your phone can reach it. That is the whole difference. If you want the no-code walkthrough on its own, running WhatsApp from Claude covers it.
How do you connect the hosted MCP server so an agent gets WhatsApp tools with no code?
Point your MCP client at https://api.blueticks.co/mcp, authenticate through the browser OAuth flow, and the client discovers nine WhatsApp tools automatically. In Claude Code that is one CLI command plus one slash command. On claude.ai it is the "Add custom connector" button. No tool definitions to write.
For Claude Code:
claude mcp add --transport http blueticks https://api.blueticks.co/mcp
Then run /mcp inside the session, select the server, and choose Authenticate. Your browser opens the consent screen and the status flips to connected. On claude.ai, go to Settings then Connectors, click Add custom connector, paste the same URL, and hit Connect. Anthropic documents both paths in the Claude Code MCP guide and the remote MCP connector docs.
The nine tools your claude whatsapp client receives are scheduled_messages, chats, contacts, groups, campaigns, audiences, engine, webhooks, and utils. Each takes a required action string. scheduled_messages accepts send, get, list, update, cancel, and ack.
Two failure modes worth knowing before you debug the wrong thing. First, the server is Streamable HTTP only, and the JSON-RPC session runs over POST. A plain GET https://api.blueticks.co/mcp in a browser returns a small 200 discovery document listing the server's capabilities and tools, not an MCP session, so seeing JSON there tells you nothing about whether your client can connect. The legacy SSE endpoint at /mcp/sse returns 405 by design: the MCP specification deprecated the standalone HTTP+SSE transport back in protocol revision 2025-03-26, and the current revision is 2026-07-28. If your client is trying to open an SSE stream, that is the bug. Second, PKCE is mandatory and S256 is the only accepted challenge method. A client that omits code_challenge gets a 400 invalid_request with "PKCE required: code_challenge (S256) is mandatory", which some older MCP clients will surface as a generic connection failure.
How do you write a WhatsApp tool definition for your own agent?
Define one tool with two required parameters, chat_id and text, then have your executor POST to /v1/scheduled-messages/{chat_id}. The critical detail every framework gets wrong the first time: the recipient is a URL path segment, not a body field. The body carries only type and the content.

Claude's Messages API uses name, description, and input_schema, per Anthropic's tool use docs:
{
"name": "send_whatsapp_message",
"description": "Send a WhatsApp message from the user's own connected number. Use for reminders, confirmations, and replies to people who have messaged first.",
"input_schema": {
"type": "object",
"properties": {
"chat_id": {
"type": "string",
"description": "Recipient as a WhatsApp chat id: '<phone>@c.us' for a person, '<id>@g.us' for a group. E.164 like '+14155551234' also works."
},
"text": {
"type": "string",
"description": "Message body, max 4096 characters."
},
"send_at": {
"type": "string",
"description": "Optional ISO 8601 timestamp with offset to schedule instead of sending now. Must be 10 seconds to 365 days in the future."
}
},
"required": ["chat_id", "text"]
}
}
OpenAI's Responses API flattens the same schema and wants strict turned on. OpenAI's function calling guide recommends always enabling strict mode, which requires additionalProperties: false:
tools = [{
"type": "function",
"name": "send_whatsapp_message",
"description": "Send a WhatsApp message from the user's own connected number.",
"parameters": {
"type": "object",
"properties": {
"chat_id": {"type": "string"},
"text": {"type": "string"},
},
"required": ["chat_id", "text"],
"additionalProperties": False,
},
"strict": True,
}]
LangChain infers the schema from type hints, so the docstring is the description. The LangChain tools docs note that type hints are required:
from langchain.tools import tool
import os, requests
@tool
def send_whatsapp_message(chat_id: str, text: str) -> str:
"""Send a WhatsApp message from the user's own connected number.
Args:
chat_id: Recipient chat id, e.g. '14155551234@c.us'
text: Message body, max 4096 characters
"""
r = requests.post(
f"https://api.blueticks.co/v1/scheduled-messages/{chat_id}",
headers={"Authorization": f"Bearer {os.environ['BLUETICKS_API_KEY']}"},
json={"type": "text", "text": text},
timeout=30,
)
r.raise_for_status()
return r.json()["data"]["id"]
Note the type field in that body. It is required and validation-only: text, media, or poll. A request without it fails schema validation before it reaches WhatsApp. For media you swap in mediaUrl (https only) and optionally mediaKind. For polls you send pollQuestion plus a pollOptions array of 2 to 12 items.
How does the agent send a message, and what does the response actually look like?
A send is one POST to /v1/scheduled-messages/{chatId} with a bearer key. It returns 201 Created and a body wrapped in {"success": true, "data": {...}}. An immediate send comes back status: "confirmed" with the real WhatsApp message key. A send with sendAt comes back status: "pending" with a queue id.
curl -X POST https://api.blueticks.co/v1/scheduled-messages/14155551234@c.us \
-H "Authorization: Bearer bt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1024-shipped" \
-d '{"type": "text", "text": "Your order #1024 shipped. Tracking: https://example.com/t/1024"}'
The response for an immediate send:
{
"success": true,
"data": {
"id": "true_14155551234@c.us_3EB0C7...",
"waMessageKey": {
"fromMe": true,
"remote": "14155551234@c.us",
"id": "3EB0C7...",
"_serialized": "true_14155551234@c.us_3EB0C7..."
},
"to": "14155551234@c.us",
"type": "text",
"text": "Your order #1024 shipped. Tracking: https://example.com/t/1024",
"status": "confirmed",
"createdAt": "2026-07-29T09:14:22.108Z"
}
}
Four things your executor must handle correctly:
- Read
data, not the top level. Every 2xx on/v1is envelope-wrapped.resp.json()["data"]["id"], neverresp.json()["id"]. sendAtis camelCase, and it must be at least 10 seconds and at most 365 days in the future. Anything else is a 400.- Null fields are stripped from the wire. A pending scheduled message has no
waMessageKeykey at all rather than anullone. Check with.get(), notis None. - The message key field is
waMessageKey, notkey. Its_serializedvalue is what every per-message read endpoint takes.
Add "sendAt": "2026-07-30T09:00:00Z" to that body and you get {"id": "6a52f1c9...", "status": "pending", "sendAt": "..."} instead, with a 24-hex queue id you can PATCH or cancel until it dispatches.
Give your agent a WhatsApp number in 10 minutes. Connect the number you already use, mint a bt_live_ key, and paste the tool definition above. No Meta business verification, no template approval queue, no per-message fee, and the message lands from a number your contacts recognise. Mint your API key and send the first message.
How does the agent read chats and receive inbound messages via signed webhooks?
Reading is two GET endpoints: /v1/chats for conversations and /v1/messages with an optional chatId query param for message history. Inbound is a webhook you register once. Since backend 20260728_1347, deliveries are HMAC-signed, so your handler can verify them.

curl "https://api.blueticks.co/v1/chats?limit=20&filter=contacts" \
-H "Authorization: Bearer bt_live_..."
curl "https://api.blueticks.co/v1/messages?chatId=14155551234@c.us&limit=50&order=desc" \
-H "Authorization: Bearer bt_live_..."
Register a webhook and keep the secret it returns:
curl -X POST https://api.blueticks.co/v1/webhooks \
-H "Authorization: Bearer bt_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/hook",
"events": ["new_message_received_webhook", "message.delivered", "message.failed"],
"description": "agent inbound"
}'
The response carries signingSecret, prefixed whsec_. Read this part carefully or you will chase a ghost: the signing secret is issued only for newly created webhooks. A webhook you registered before signing shipped has no secret, gets no signature headers, and keeps delivering unsigned. If your verification code rejects everything, check GET /v1/webhooks for a signingSecret field first. If it is missing, recreate the webhook.
Every signed delivery carries three headers. Blueticks-Webhook-Timestamp is unix seconds. Blueticks-Webhook-Signature is v1=<hex>, an HMAC-SHA256 over the exact string "{timestamp}.{rawBody}". X-Blueticks-Signature is sha256=<hex> over the raw body alone, for handlers that skip replay windows.
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["BLUETICKS_WEBHOOK_SECRET"].encode()
@app.post("/hook")
def hook():
raw = request.get_data() # raw bytes, BEFORE any JSON parsing
ts = request.headers.get("Blueticks-Webhook-Timestamp", "")
sig = request.headers.get("Blueticks-Webhook-Signature", "")
expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig.removeprefix("v1="), expected):
abort(401)
event = request.get_json()
# {"id": ..., "type": "message.delivered", "created_at": ..., "data": {...}}
if event["type"] == "message.delivered":
print(event["data"]["waMessageKey"]["_serialized"], event["data"]["status"])
return "", 200
Hash the raw request bytes. Re-serializing the parsed object drifts on key order and whitespace and fails every check. That single mistake accounts for most signature-verification support tickets. The event envelope is {id, type, created_at, data}, where created_at is snake_case (the envelope only) and data is the same public message object the send endpoint returns.
What breaks in production: rate limits, retries, idempotency, and delivery status
Four things bite at scale: the plan quota, the per-key buckets, retry duplication, and mistaking "accepted" for "delivered". All four have concrete numbers you can code against, and 429s now carry real headers so you can back off instead of guessing.

Rate limits. Free accounts get 5 API requests per 6-hour window, shared across REST and MCP, so it is a build-and-test budget rather than a production one. Any active subscription lifts that. On top of it, per-key buckets apply: 60 sends per minute, 600 message reads per minute, 120 chat reads per minute. A 429 returns Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining. Read those instead of sleeping a fixed interval.
Idempotency. Agents retry. A timed-out POST that actually succeeded will double-send unless you pass an Idempotency-Key header (64 chars max, scoped per workspace). Reuse the same key with the same body and the original response replays. The gotcha: a replay returns 201, the same status as the original, not 200. Detect a replay by comparing the returned message id, never by the status code. A different body under the same key returns 409 Conflict. And do not confuse this with the body's secret field, which is a correlation tag only and does not deduplicate anything.
Delivery status is a lifecycle, not a boolean. The enum runs pending to confirmed to received to read, then played or failed. confirmed means WhatsApp accepted it. It does not mean anyone saw it. If your agent tells a user "delivered" on confirmed, it is lying by one hop. Subscribe to message.delivered, message.read, and message.failed and branch on event.data.status.
Webhook retries. A failed delivery retries up to 8 times on a backoff that stretches from 1 minute to 12 hours, with a 10-second per-attempt timeout. After 8 consecutive failures the webhook auto-disables. Your handler must be idempotent, because a slow 200 still counts as a failure and the same event will arrive twice.
What this is not: own number, not the Meta Cloud API
This runs your personal or business WhatsApp account through WhatsApp Web or a managed gateway. It is not Meta's sanctioned Business Platform, it carries account risk, and nobody honest will promise you otherwise. Consent for every message you send is yours to obtain, not something an API can launder.
The limits, plainly:
- Account risk is real. WhatsApp's Help Center states that "Our products are not intended for bulk or automated messaging, both of which have always been a violation of our Terms of Service", and that it will use legal action against those it determines are engaged in such abuse. A separate page warns that "using an unauthorized application and/or unsupported device violates our Terms of Service and can result in your account being banned". There is no no-ban guarantee here. Agents that reply to people who messaged first, send reminders users asked for, and pace their outbound sit far from the enforcement line. Cold blasting strangers does not, whatever the transport.
- Consent is on you. Your agent will happily send to any
chat_idit is handed. Gate that list in your own code. - Personal-account scale. No enterprise throughput tier, no priority queue.
- Session dependency. WhatsApp allows up to four linked devices at a time, and its own docs note that linked devices "will log out if your phone is unused for over 14 days." A cloud gateway removes the laptop from that chain, not the phone.
If enterprise volume, template messaging, and Meta's contractual support are what you actually need, the Cloud API is the correct product. Pay the per-message fee and the setup time.
Where the DIY path ends and Blueticks starts
Self-hosted whatsmeow and Baileys MCP servers are genuinely fine for a single developer on one machine. They stop working when the agent needs to run without you: overnight scheduling, a session that survives a laptop lid, delivery status you can query, and OAuth so a phone-based client can reach it.
That is the line. Below it, clone the repo. Above it, the managed pieces are the ones that take real operational work to rebuild:
- A gateway that stays connected, running server-side so a closed laptop does not stop your agent mid-run.
- Scheduling on the same call. Add
sendAtand the message queues, withPATCHand cancel available until it dispatches. No second scheduler service. - Signed webhooks with retries. 8 attempts, backoff, auto-disable, HMAC. That is a week of work to do properly.
- A hosted OAuth MCP server so Claude on your phone reaches the same number as your backend cron job.
- Idempotency built into the send path, which is what stops a retrying agent from texting a customer twice.
The REST side of this, without the agent framing, is covered in the own-number WhatsApp REST API walkthrough.
Frequently Asked Questions
Can an AI agent send WhatsApp messages from my own number?
Yes. A whatsapp ai agent calls POST /v1/scheduled-messages/{chatId} with a bearer API key, and the message goes out from your existing connected WhatsApp account. There is no Meta business verification and no template approval. The recipient sees the number they already have saved for you.
Is this the same as the WhatsApp Cloud API?
No. The Cloud API is Meta's sanctioned platform, bills per delivered template message since July 1, 2025, and requires a dedicated business number that cannot already be in use with WhatsApp Messenger. This runs your own existing account over WhatsApp Web or a managed gateway. Different product, different tradeoffs, different risk profile.
What is the difference between the MCP server and the REST API?
The whatsapp mcp server is for clients that run their own agent loop, like Claude. You paste a URL, authenticate with OAuth, and the client discovers nine tools. The REST API is for when you own the executor and want a typed tool definition your code invokes. Same engine, same number, same connected session underneath.
Why is my webhook signature verification failing?
Three usual causes. You are hashing re-serialized JSON instead of the raw request bytes. You forgot to strip the v1= prefix before comparing. Or the webhook predates signing and has no signingSecret, in which case it delivers unsigned and there is nothing to verify. Check GET /v1/webhooks and recreate it if the field is absent.
Will automating WhatsApp with AI get my number banned?
It can. WhatsApp's terms prohibit bulk and automated messaging, and unauthorized clients are grounds for a ban. Nobody can promise otherwise. What lowers the risk is behaviour: reply to people who contacted you first, send only what recipients asked for, pace your volume, and keep an opt-out. To automate whatsapp with ai safely, build a responder rather than a broadcaster.
How do I check whether my number is actually connected before the agent sends?
Call GET /v1/engines. It returns an array of connected engines with connected, state, and hasSynced fields. An empty array means nothing is paired, which is the check worth putting in front of every agent send so a failure surfaces as "not connected" rather than a confusing send error.



