What You'll End Up With
- A WhatsApp Business bot that receives messages via webhook
- Python handler that processes incoming messages and responds
- Caddy reverse proxy with automatic HTTPS
- systemd service that auto-restarts on crash
- Phone number allowlist for access control
- Message logging for audit trail
Prerequisites
- A VPS running Ubuntu 24.04 (tested on Hetzner CX22, $5/mo)
- A WhatsApp Business account with the Cloud API
- A domain pointing to your VPS IP
- Python 3.12+ installed
Step 1: Get WhatsApp Business API Access
Go to Meta Business Suite and create a business account. Then:
- Create a Business App in Meta for Developers
- Add the WhatsApp product to your app
- Note your Phone Number ID and Access Token
- Add your test phone number to the allowlist
Step 2: Set Up the Webhook Handler
Create the project directory:
sudo mkdir -p /opt/wabot
sudo chown $USER:$USER /opt/wabot
cd /opt/wabot
Create handler.py:
#!/usr/bin/env python3
"""WhatsApp Business Cloud API webhook handler."""
import json
import os
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
PHONE_NUMBER_ID = os.environ.get("WA_PHONE_NUMBER_ID", "")
ACCESS_TOKEN = os.environ.get("WA_ACCESS_TOKEN", "")
VERIFY_TOKEN = os.environ.get("WA_VERIFY_TOKEN", "my_verify_token")
ALLOWED_PHONES = os.environ.get("WA_ALLOWED_PHONES", "").split(",")
class WhatsAppHandler(BaseHTTPRequestHandler):
def do_GET(self):
# Webhook verification
if self.path.startswith("/webhook"):
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
mode = params.get("hub.mode", [""])[0]
token = params.get("hub.verify_token", [""])[0]
challenge = params.get("hub.challenge", [""])[0]
if mode == "subscribe" and token == VERIFY_TOKEN:
self.send_response(200)
self.end_headers()
self.wfile.write(challenge.encode())
else:
self.send_response(403)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path == "/webhook":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
data = json.loads(body)
# Process incoming messages
for entry in data.get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
for msg in value.get("messages", []):
phone = msg.get("from", "")
text = msg.get("text", {}).get("body", "")
if phone not in ALLOWED_PHONES:
print(f"Blocked: {phone}")
continue
# Your logic here
reply = f"You said: {text}"
send_whatsapp(phone, reply)
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
else:
self.send_response(404)
self.end_headers()
def send_whatsapp(to, message):
url = f"https://graph.facebook.com/v18.0/{PHONE_NUMBER_ID}/messages"
payload = json.dumps({
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": message}
}).encode()
req = urllib.request.Request(url, data=payload, headers={
"Authorization": f"Bearer {ACCESS_TOKEN}",
"Content-Type": "application/json"
})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
print(f"Sent to {to}: {resp.status}")
except Exception as e:
print(f"Send failed: {e}")
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8888), WhatsAppHandler)
print("WhatsApp bot running on :8888")
server.serve_forever()
Step 3: Install Dependencies
cd /opt/wabot
python3 -m venv venv
source venv/bin/activate
# No external deps needed - uses stdlib only!
pip install --upgrade pip
flask or fastapi later if you need more features.Step 4: Set Environment Variables
sudo tee /etc/wabot.env <<EOF
WA_PHONE_NUMBER_ID=your_phone_number_id
WA_ACCESS_TOKEN=your_access_token
WA_VERIFY_TOKEN=your_custom_verify_token
WA_ALLOWED_PHONES=50123456789,50198765432
EOF
sudo chmod 600 /etc/wabot.env
sudo chown root:root /etc/wabot.env
Step 5: Caddy Reverse Proxy
Install Caddy (if not already installed):
sudo apt install -y caddy
Edit /etc/caddy/Caddyfile:
wabot.yourdomain.com {
reverse_proxy localhost:8888
}
sudo systemctl restart caddy
Caddy handles HTTPS automatically. Your webhook URL is now https://wabot.yourdomain.com/webhook.
Step 6: Configure the Webhook in Meta
Back in Meta for Developers:
- Go to your app → WhatsApp → Configuration
- Set Callback URL to
https://wabot.yourdomain.com/webhook - Set Verify Token to the same value as
WA_VERIFY_TOKEN - Subscribe to
messagesfield - Click "Verify and Save"
Step 7: systemd Service
sudo tee /etc/systemd/system/wabot.service <<EOF
[Unit]
Description=WhatsApp Bot Handler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/wabot
EnvironmentFile=/etc/wabot.env
ExecStart=/opt/wabot/venv/bin/python3 handler.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wabot
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/wabot
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable wabot
sudo systemctl start wabot
sudo systemctl status wabot should show "active (running)".Step 8: Test End-to-End
Send a WhatsApp message from your test phone to your business number. The bot should reply with "You said: [your message]".
# Check logs if nothing happens
sudo journalctl -u wabot -n 20 --no-pager
# Check Caddy is proxying
curl -s https://wabot.yourdomain.com/webhook -X GET
# Should return 403 (no verify token)
Verification Checklist
| Check | Command | Expected |
|---|---|---|
| Service running | systemctl is-active wabot | active |
| Webhook reachable | curl -s https://wabot.yourdomain.com/webhook | 403 or 200 |
| HTTPS valid | Check browser | Valid cert, no warnings |
| Message round-trip | Send WhatsApp message | Bot replies |
| Allowlist works | Message from unlisted number | No reply, log shows "Blocked" |
What's Next
Add AI Processing
Replace the reply = f"You said: {text}" line with a call to your LLM of choice:
def call_llm(prompt):
url = "https://api.openai.com/v1/chat/completions"
payload = json.dumps({
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": prompt}]
}).encode()
req = urllib.request.Request(url, data=payload, headers={
"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}",
"Content-Type": "application/json"
})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"]
# In your message handler:
reply = call_llm(f"Customer said: {text}\nRespond helpfully.")
Add Media Handling
WhatsApp messages can include images, audio, documents. The webhook payload changes:
msg_type = msg.get("type", "text")
if msg_type == "image":
image_id = msg["image"]["id"]
# Download via media endpoint
# Process with vision model
elif msg_type == "audio":
audio_id = msg["audio"]["id"]
# Download and transcribe
Production Hardening
- Rate limiting: Add
nginxorCaddyrate limits - Message queue: Use Redis or SQLite for message persistence
- Error tracking: Add Sentry or structured logging
- Health checks: Add
/healthendpoint for monitoring - Backup: Cron job to backup conversation logs
Troubleshooting
Webhook verification fails
Meta can't reach your server. Check:
- DNS:
nslookup wabot.yourdomain.comreturns your VPS IP - Caddy running:
sudo systemctl status caddy - Port 443 open:
sudo ufw allow 443/tcp - Verify token matches in Meta dashboard and
/etc/wabot.env
Messages not received
sudo journalctl -u wabot -n 50 --no-pager
Look for "Blocked: [phone]" - means the sender isn't in WA_ALLOWED_PHONES.
Replies not sent
Check that WA_PHONE_NUMBER_ID and WA_ACCESS_TOKEN are correct. The token must have whatsapp_business_messaging permission.
Service crashes repeatedly
sudo journalctl -u wabot --since "5 min ago" --no-pager
Common cause: missing environment variables. Check sudo cat /etc/wabot.env has all 4 values.
Cost Breakdown
| Item | Cost | Notes |
|---|---|---|
| Hetzner CX22 VPS | $5/mo | 4GB RAM, 40GB disk |
| Domain | $10/yr | Any registrar |
| WhatsApp Business API | Free tier | 1,000 free conversations/month |
| Caddy | Free | Auto-HTTPS |
| Total | ~$6/mo |