Wire a real WhatsApp number to Anthropic's Claude models. WABridges handles the WhatsApp connection; Claude handles the conversation. Every inbound message becomes a webhook, every reply is one REST call.
Claude is great at conversation: but it doesn't come with a phone number. WABridges is the missing half: a real WhatsApp number under full API control, with no WhatsApp Business API approval queue and no per-message billing.
Point your WABridges webhook at a small handler, forward each message to the Anthropic Messages API, and send Claude's answer back. You keep the conversation history, the system prompt, and the model choice, everything runs on your own backend.
We keep the number online and hand you every event. You write the part in the middle.
One API call creates a bridge (your first is free, no card). Scan the QR code with any WhatsApp number, personal, VoIP, or a spare SIM, about a minute with the phone in hand. No phone yet? Every account has a sandbox bridge to build against.
Configure the bridge to POST inbound messages to your endpoint. Every message someone sends the number arrives as JSON.
In your handler, call the Anthropic Messages API (POST https://api.anthropic.com/v1/messages) with the message text, a system prompt, and per-chat history. Use a current model such as claude-sonnet-5, or claude-haiku-4-5 for the fastest replies.
POST Claude's answer back to the WABridges send endpoint. Respond to the webhook with 200 immediately and do the Claude call asynchronously so WhatsApp doesn't retry.
One file, about 80 lines: webhook in, Claude, /send/text out. It verifies the webhook signature, acks before calling the model, keeps per-chat history, de-duplicates retries, and honors Retry-After when an idle bridge wakes. Same program in Node and Python.
// WABridges + Claude: a WhatsApp bot in one file.
// Webhook in -> Claude -> POST /send/text out. Run it against the sandbox
// first (no phone needed), then point CUSTOMER_REF at a real bridge.
import express from "express";
import crypto from "node:crypto";
import Anthropic from "@anthropic-ai/sdk";
const { WA_API_KEY, WA_WEBHOOK_SECRET, ANTHROPIC_API_KEY, CUSTOMER_REF = "sandbox", PORT = 3000 } = process.env;
const BASE = `https://wabridges.com/api/instances/${CUSTOMER_REF}/proxy`;
// Customize me.
const SYSTEM_PROMPT = `You are a friendly assistant answering on WhatsApp.
Keep replies short (1-3 sentences), plain text, no markdown.`;
const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
const history = new Map(); // chat_id -> Anthropic.MessageParam[] (last 10 exchanges)
const seen = new Set(); // X-Webhook-Event-Id dedupe: delivery is at-least-once
const app = express();
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
// Same check as https://wabridges.com/docs/webhooks#verify
function verifyWebhook(req) {
const m = (req.get("X-Webhook-Signature") || "").match(/^t=(\d+),v1=([0-9a-f]+)$/);
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // stale
const expected = crypto.createHmac("sha256", WA_WEBHOOK_SECRET).update(`${t}.`).update(req.rawBody).digest("hex");
return v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
app.post("/hook", (req, res) => {
if (!verifyWebhook(req)) return res.sendStatus(401);
res.sendStatus(200); // ack first: the bridge retries anything slower than 10s
const ev = req.body;
const id = req.get("X-Webhook-Event-Id");
if (ev.event !== "message" || ev.from_me || ev.is_group || ev.type !== "text") return;
if (seen.has(id)) return;
seen.add(id);
if (seen.size > 1000) seen.delete(seen.values().next().value);
reply(ev.chat_id, ev.body, id).catch((e) => console.error("reply failed:", e));
});
async function reply(chat, text, eventId) {
const msgs = history.get(chat) ?? [];
msgs.push({ role: "user", content: text });
let answer;
try {
const r = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024, // WhatsApp replies are short on purpose
output_config: { effort: "low" }, // chat: fast and cheap; raise for harder tasks
system: SYSTEM_PROMPT,
messages: msgs,
});
answer = r.stop_reason === "refusal"
? "Sorry, I can't help with that one."
: r.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
} catch (e) {
answer = e instanceof Anthropic.RateLimitError ? "I'm a bit busy, try again in a minute." : "Something went wrong on my side.";
console.error("claude:", e.message);
}
answer ||= "...";
msgs.push({ role: "assistant", content: answer });
history.set(chat, msgs.slice(-20));
await sendText(chat, answer, eventId);
}
async function sendText(chat, body, idempotencyKey, retried = false) {
const res = await fetch(`${BASE}/send/text`, {
method: "POST",
headers: { Authorization: `Bearer ${WA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
body: JSON.stringify({ chat, body }),
});
if (res.status === 503 && !retried) { // an idle bridge is waking up: honor Retry-After once
await new Promise((r) => setTimeout(r, 1000 * (Number(res.headers.get("Retry-After")) || 5)));
return sendText(chat, body, idempotencyKey, true);
}
if (!res.ok) console.error("send failed:", res.status, await res.text());
}
app.listen(PORT, () => console.log(`bot listening on :${PORT} for bridge "${CUSTOMER_REF}"`));
"""WABridges + Claude: a WhatsApp bot in one file.
Webhook in -> Claude -> POST /send/text out. Run it against the sandbox first
(no phone needed), then point CUSTOMER_REF at a real bridge.
"""
import hashlib
import hmac
import os
import re
import threading
import time
from collections import OrderedDict, defaultdict, deque
import anthropic
import requests
from flask import Flask, request
WA_API_KEY = os.environ["WA_API_KEY"]
WA_WEBHOOK_SECRET = os.environ["WA_WEBHOOK_SECRET"]
CUSTOMER_REF = os.environ.get("CUSTOMER_REF", "sandbox")
BASE = f"https://wabridges.com/api/instances/{CUSTOMER_REF}/proxy"
# Customize me.
SYSTEM_PROMPT = """You are a friendly assistant answering on WhatsApp.
Keep replies short (1-3 sentences), plain text, no markdown."""
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
history = defaultdict(lambda: deque(maxlen=20)) # chat_id -> last 10 exchanges
seen = OrderedDict() # X-Webhook-Event-Id dedupe: delivery is at-least-once
app = Flask(__name__)
def verify_webhook() -> bool:
"""Same check as https://wabridges.com/docs/webhooks#verify"""
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]+)", request.headers.get("X-Webhook-Signature", ""))
if not m:
return False
t, v1 = m.groups()
if abs(time.time() - int(t)) > 300: # stale
return False
expected = hmac.new(WA_WEBHOOK_SECRET.encode(), f"{t}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
@app.post("/hook")
def hook():
if not verify_webhook():
return "", 401
ev = request.get_json(silent=True) or {}
event_id = request.headers.get("X-Webhook-Event-Id", "")
if ev.get("event") != "message" or ev.get("from_me") or ev.get("is_group") or ev.get("type") != "text":
return "", 200
if event_id in seen:
return "", 200
seen[event_id] = True
if len(seen) > 1000:
seen.popitem(last=False)
# Ack now: the bridge retries anything slower than 10s. Reply in the background.
threading.Thread(target=reply, args=(ev["chat_id"], ev["body"], event_id), daemon=True).start()
return "", 200
def reply(chat: str, text: str, event_id: str) -> None:
msgs = history[chat]
msgs.append({"role": "user", "content": text})
try:
r = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024, # WhatsApp replies are short on purpose
output_config={"effort": "low"}, # chat: fast and cheap; raise for harder tasks
system=SYSTEM_PROMPT,
messages=list(msgs),
)
if r.stop_reason == "refusal":
answer = "Sorry, I can't help with that one."
else:
answer = "\n".join(b.text for b in r.content if b.type == "text").strip()
except anthropic.RateLimitError:
answer = "I'm a bit busy, try again in a minute."
except anthropic.APIError as e:
print("claude:", e)
answer = "Something went wrong on my side."
answer = answer or "..."
msgs.append({"role": "assistant", "content": answer})
send_text(chat, answer, event_id)
def send_text(chat: str, body: str, idempotency_key: str, retried: bool = False) -> None:
res = requests.post(
f"{BASE}/send/text",
json={"chat": chat, "body": body},
headers={"Authorization": f"Bearer {WA_API_KEY}", "Idempotency-Key": idempotency_key},
timeout=30,
)
if res.status_code == 503 and not retried: # an idle bridge is waking up: honor Retry-After once
time.sleep(int(res.headers.get("Retry-After", "5")))
return send_text(chat, body, idempotency_key, True)
if not res.ok:
print("send failed:", res.status_code, res.text)
if __name__ == "__main__":
app.run(port=int(os.environ.get("PORT", "3000")))
Download: node/bot.js · python/bot.py · README
Every account has a sandbox bridge that talks back, so the whole loop is testable before you pair a number.
sk_) and an Anthropic API key.ngrok http 3000 or cloudflared tunnel --url http://localhost:3000.webhook_secret.WA_API_KEY, WA_WEBHOOK_SECRET and ANTHROPIC_API_KEY set (npm install && node bot.js or pip install -r requirements.txt && python bot.py).POST …/sandbox/proxy/send/text to 15550001234. She replies, Claude answers, she answers back. After about six exchanges she says "Looks like we're looping" and goes quiet for a minute: that is the sandbox's loop guard, and seeing it means the whole loop works.curl -X PATCH https://wabridges.com/api/instances/sandbox \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"webhook_url": "https://<your-tunnel>/hook"}'
Three steps between the sandbox and a phone people can message.
https://<your-tunnel>/hook, on the bridge page or with PATCH /api/instances/<customer_ref>. Keep the webhook_secret. More bridges later: POST /api/instances.POST …/proxy/pair for a code. About a minute, phone in hand. Any number works: personal, VoIP, a spare SIM.CUSTOMER_REF=claude-bot and that bridge's secret. Message the number from another phone.Before production: move history out of memory, decide what to do in groups, and add a per-chat rate limit. Signature verification is the same snippet as the webhooks docs.
Every loop below is the one above with your own logic in the middle.
Not Node or Python? Copy this prompt into Claude Code, claude.ai or any AI assistant and get the same bot in your language.
I want to connect a WhatsApp number to Claude using WABridges for the WhatsApp layer and the Anthropic Messages API for the replies. There is a reference implementation in Node and Python at https://wabridges.com/examples/claude-bot/README.md - match its behavior. My stack: [Node.js: update this] My setup: - WA_API_KEY: my WABridges API key - ANTHROPIC_API_KEY: my Anthropic API key - Bridge customer_ref: "claude-bot" I need: 1. A webhook endpoint that receives inbound messages from WABridges 2. Conversation history tracked per chat_id (in-memory is fine to start) 3. Each turn sent to the Anthropic Messages API with a system prompt I can customize, using model "claude-sonnet-5" (or "claude-haiku-4-5" for lower latency) 4. Claude's reply sent back via the WABridges send API 5. The webhook must return 200 immediately and call Claude asynchronously so WhatsApp doesn't re-deliver the message Please read both sets of docs before writing any code: - WABridges API: https://wabridges.com/llms-full.txt - Anthropic API: https://docs.anthropic.com/en/api/messages Keep it under ~70 lines. Add a comment where I customize the system prompt, and show me how to swap the model.
What you get on the WhatsApp side, whatever you build on yours.
Other guides that build on the loop you just read.
Choosing a provider? See how WABridges compares to Twilio, 360dialog, and other WhatsApp API providers.