WABridges
AI agents

Connect WhatsApp
to Claude.

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.

Four steps. One loop.

We keep the number online and hand you every event. You write the part in the middle.

1
Provision a bridge & pair a number

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.

2
Set your webhook URL

Configure the bridge to POST inbound messages to your endpoint. Every message someone sends the number arrives as JSON.

3
Forward to Claude

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.

Reply via WABridges

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.

The bot, runnable.

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

Run it against the sandbox first. No phone.

Every account has a sandbox bridge that talks back, so the whole loop is testable before you pair a number.

  1. Keys. A WABridges API key (Dashboard → API keys, starts with sk_) and an Anthropic API key.
  2. A public URL for your laptop: ngrok http 3000 or cloudflared tunnel --url http://localhost:3000.
  3. Point the sandbox at it. The response carries the sandbox webhook_secret.
  4. Run the bot with 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).
  5. Say hi to Alice from the dashboard's sandbox card or with 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.
PATCH /api/instances/sandbox
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"}'

Then a real number.

Three steps between the sandbox and a phone people can message.

  1. Connect a number from the dashboard (one click; free for 7 days with a card on file, nothing charged until then). Set its webhook URL to 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.
  2. Pair a phone: scan the QR on the bridge page, or POST …/proxy/pair for a code. About a minute, phone in hand. Any number works: personal, VoIP, a spare SIM.
  3. Restart the bot with 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.

The same three calls. Different jobs.

Every loop below is the one above with your own logic in the middle.

🤝 AI concierge
  • Answer FAQs 24/7
  • Book and reschedule appointments
  • Look up order status
  • Escalate to a human on request
🧠 Personal assistant
  • Summarize long threads
  • Draft replies in your voice
  • Translate on the fly
  • Set reminders via natural language
🛠️ Tool-using agent
  • Claude tool use / function calling
  • Query your database over chat
  • Trigger workflows from a message
  • Return charts and documents
🗂️ Context-aware support
  • Per-chat conversation memory
  • Inject customer records into the prompt
  • Route by intent
  • Keep full transcripts in your DB

Or have your AI assistant write it for your stack

Not Node or Python? Copy this prompt into Claude Code, claude.ai or any AI assistant and get the same bot in your language.

prompt
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.

One number, one price, no approval queue.

What you get on the WhatsApp side, whatever you build on yours.

Real WhatsApp number for Claude: no WhatsApp Business API approval
Works with any current Claude model: Opus 4.8, Sonnet 5, or Haiku 4.5
Inbound as webhooks, replies as REST, no SDK required on the WhatsApp side
Per-chat history and system prompts stay on your backend, with your data
Group chat support and rich media: send text, images, docs, and voice notes
Flat $5/month per bridge: no per-message fees, no matter how chatty Claude gets