whatsapp-bot
Reference WhatsApp bot using `@theokit/gateway-whatsapp` (Meta WhatsApp Business Cloud API backend).
whatsapp-bot
Reference WhatsApp bot using @theokit/gateway-whatsapp (Meta WhatsApp Business Cloud API backend).
What you'll need
- Meta Developer App with WhatsApp product added: https://developers.facebook.com/apps
- A phone number provisioned in the app (test/sandbox is free, limit 5 recipients)
- A webhook URL publicly reachable — use ngrok locally
- An LLM provider key (this example uses OpenRouter — sign up at https://openrouter.ai)
Setup (8 steps)
1. Meta Developer App
Create an app at https://developers.facebook.com/apps. Add the WhatsApp product.
2. Get your phone-number-id + access token
In the WhatsApp product setup:
- Phone number ID (e.g.
123456789012345) — copy it. - Temporary access token — copy it for now (24h validity). For longer-lived auth, generate a system-user permanent token.
3. Get your app secret
App Dashboard → Settings → Basic → App Secret → "Show" → copy.
4. Pick a verify token
Any random string you'll match between Meta Console and your .env (e.g. theo-whatsapp-verify-2026).
5. Configure .env
cp .env.example .envFill in:
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_ACCESS_TOKEN=EAAxxxxx...
WHATSAPP_APP_SECRET=abc123def456...
WHATSAPP_VERIFY_TOKEN=theo-whatsapp-verify-2026
OPENROUTER_API_KEY=sk-or-v1-...
PORT=30006. Expose your local server
# In one terminal:
ngrok http 3000
# Note the https:// URL ngrok prints (e.g. https://ab12cd34.ngrok-free.app)7. Tell Meta the webhook URL
In the WhatsApp product setup, Configuration → Webhook:
- Callback URL:
https://YOUR-NGROK-URL/webhook - Verify token: the same string you put in
.env(theo-whatsapp-verify-2026) - Click Verify and save — Meta hits
GET /webhook?hub.mode=subscribe&hub.challenge=...and your server must echo the challenge. The example does this for you. - Subscribe to
messagesfield.
8. Pre-register test recipients
Important sandbox rule: WhatsApp Business sandbox can only message PRE-REGISTERED phone numbers (max 5). In the WhatsApp setup, add the WhatsApp number you'll test from as a "Recipient phone number".
Run
pnpm install
pnpm run runYou should see:
[whatsapp-bot] listening on http://localhost:3000
[whatsapp-bot] webhook URL: http://localhost:3000/webhookNow send a WhatsApp message from your pre-registered phone to the WhatsApp Business test number. The bot replies via the agent.
Smoke test (one-shot send)
If you just want to verify the credentials work without setting up the webhook:
# Add WHATSAPP_TEST_PHONE=+5511999999999 to .env (pre-registered!)
pnpm run smokeThe smoke prints either a wamid.xxx on success or a clear error.
Architecture
Phone (sender)
│
▼
Meta WhatsApp Business Cloud API
│ (sends webhook POST + signature header)
▼
ngrok → http://localhost:3000/webhook
│
▼
Express + verifyWebhookSignature (HMAC-SHA256)
│
▼
WhatsAppCloudBackend.handleWebhookPayload
│
▼
WhatsAppAdapter.onInbound handler
│ (wraps in MessageEvent)
▼
Agent.create / agent.send (LLM call)
│
▼
adapter.sendMessage → WhatsAppCloudBackend.send → Meta API
│
▼
Recipient phone (response delivered)Notes on Express middleware (important — EC-2)
The bot uses express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }) — the verify callback preserves the raw bytes BEFORE JSON parsing. Without this, the signature verification (HMAC of rawBody) ALWAYS fails because Express discards the original byte stream.
If you switch to Hono / Cloudflare Workers / Next.js routes, use the equivalent pattern: read request.text() or request.arrayBuffer() BEFORE parsing JSON.
Troubleshooting
- "Token rejected" → access token expired. Generate a new one in the WhatsApp product setup.
- "recipient_phone_number_not_in_allowed_list" → recipient not pre-registered. Add it to the sandbox in Meta Console.
- Webhook never receives messages → verify GET handshake passed (Meta shows green "Connected") and that you subscribed to
messagesfield. - Signature always invalid → check that
verify: (req, _res, buf) => ...middleware is wired BEFORE the route handlers.
v1 limits
- Text only (image/audio/video/document inbound are ignored — see EC-4 in the plan).
- DM only (Cloud API delivers DMs via webhook; group support requires Meta-side opt-in).
- Group send works (you can send to a group id) but group RECEIVE needs a different webhook subscription.
Companion: web bridge backend
The WhatsAppWebBackend (whatsapp-web.js subprocess) is the alternative for personal/dev accounts that don't have Meta Business verification. See the package README for setup.
Code
/**
* WhatsApp bot example — Meta WhatsApp Business Cloud API backend.
*
* Demonstrates the full receive → respond → status-receipt loop.
*
* Expose this server to the public internet (ngrok / Cloudflare Tunnel) so
* Meta's webhook can reach it. See README.md for setup steps.
*/
import express from "express";
import {
verifyWebhookSubscription,
verifyWebhookSignature,
WhatsAppAdapter,
WhatsAppCloudBackend,
type WhatsAppMessageEvent,
} from "@theokit/gateway-whatsapp";
import { Agent } from "@theokit/sdk";
const PHONE_NUMBER_ID = required("WHATSAPP_PHONE_NUMBER_ID");
const ACCESS_TOKEN = required("WHATSAPP_ACCESS_TOKEN");
const APP_SECRET = required("WHATSAPP_APP_SECRET");
const VERIFY_TOKEN = required("WHATSAPP_VERIFY_TOKEN");
const PROVIDER_KEY = required("OPENROUTER_API_KEY");
const PORT = Number(process.env.PORT ?? 3000);
function required(name: string): string {
const v = process.env[name];
if (v === undefined || v.length === 0) {
console.error(`[whatsapp-bot] missing env var: ${name}`);
process.exit(1);
}
return v;
}
const backend = new WhatsAppCloudBackend({
accessToken: ACCESS_TOKEN,
phoneNumberId: PHONE_NUMBER_ID,
appSecret: APP_SECRET,
});
const adapter = new WhatsAppAdapter(backend, {
botPhoneId: PHONE_NUMBER_ID,
// requireMention defaults to true for groups; Meta Cloud webhook only
// delivers DMs by default, so this is just defensive.
});
adapter.onInbound(async (event) => {
if (event.platform !== "whatsapp") return; // narrow
const wa = event as WhatsAppMessageEvent;
console.log(`[whatsapp-bot] inbound from=${wa.sender.id} text=${wa.text.slice(0, 60)}`);
const agentId = `whatsapp-${wa.channel.id}`;
const agent = await Agent.create({
apiKey: PROVIDER_KEY,
model: { id: "openai/gpt-4o-mini" },
name: agentId,
systemPrompt: "You are a concise WhatsApp assistant. Reply in one short paragraph.",
});
try {
const run = await agent.send(wa.text);
const result = await run.wait();
await adapter.sendMessage({
channel: wa.channel,
text: result.result ?? "(no reply)",
});
} finally {
await agent.dispose();
}
});
adapter.onStatusReceipt(async (receipt) => {
console.log(`[whatsapp-bot] status wamid=${receipt.wamid} → ${receipt.status}`);
});
const app = express();
// EC-2 absorbed: preserve rawBody bytes for HMAC verification.
app.use(
express.json({
verify: (req, _res, buf) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
},
}),
);
// EC-1 absorbed: Meta GET handshake.
app.get("/webhook", (req, res) => {
const challenge = verifyWebhookSubscription(
req.query as Record<string, string | undefined>,
VERIFY_TOKEN,
);
if (challenge === null) {
res.sendStatus(403);
return;
}
res.type("text/plain").send(challenge);
});
// POST /webhook — Meta inbound callbacks.
app.post("/webhook", async (req, res) => {
const rawBody = (req as unknown as { rawBody: Buffer }).rawBody;
const sig = req.header("x-hub-signature-256");
if (!verifyWebhookSignature(rawBody, sig, APP_SECRET)) {
res.sendStatus(401);
return;
}
const ok = await backend.handleWebhookPayload(rawBody, sig);
res.sendStatus(ok ? 200 : 401);
});
app.get("/", (_req, res) => {
res.type("text/plain").send("WhatsApp bot is running. Webhook URL: /webhook");
});
await backend.connect();
const server = app.listen(PORT, () => {
console.log(`[whatsapp-bot] listening on http://localhost:${PORT}`);
console.log(`[whatsapp-bot] webhook URL: http://localhost:${PORT}/webhook`);
console.log(`[whatsapp-bot] expose via ngrok: \`ngrok http ${PORT}\``);
});
process.on("SIGINT", async () => {
console.log("\n[whatsapp-bot] shutting down");
server.close();
await adapter.disconnect();
process.exit(0);
});
Run
cd examples/whatsapp-bot
cp .env.example .env # fill in keys
pnpm install
pnpm run run