FREE GUIDE Plan 1c · Tested 2026-07-22

Deploy a WhatsApp Bot
in 30 Minutes

30 minutes · 8 steps · Built with the WhatsApp Business API + Python + Caddy

Want the full version? Multi-language support, AI vision integration, payment processing, and production hardening.

Get the Full Pack →

What You'll End Up With

Prerequisites

This is not a tutorial for spam bots. WhatsApp's Cloud API requires business verification. Use it for legitimate customer interactions only. One spam report can get your number banned.

Step 1: Get WhatsApp Business API Access

Go to Meta Business Suite and create a business account. Then:

  1. Create a Business App in Meta for Developers
  2. Add the WhatsApp product to your app
  3. Note your Phone Number ID and Access Token
  4. Add your test phone number to the allowlist
Verify: Send a test message from the Meta dashboard to your phone. If you receive it, the API is working.

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
Why no pip installs? The handler uses only Python standard library. Fewer dependencies = fewer security holes. You can add 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
Protect these secrets. The access token gives full control of your WhatsApp Business account. Never commit it to git. Never put it in a Dockerfile. Use environment files with restricted permissions.

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:

  1. Go to your app → WhatsApp → Configuration
  2. Set Callback URL to https://wabot.yourdomain.com/webhook
  3. Set Verify Token to the same value as WA_VERIFY_TOKEN
  4. Subscribe to messages field
  5. Click "Verify and Save"
Verify: If verification succeeds, Meta shows "Webhook ready". If it fails, check that Caddy is running and your DNS is correct.

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
Verify: 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

CheckCommandExpected
Service runningsystemctl is-active wabotactive
Webhook reachablecurl -s https://wabot.yourdomain.com/webhook403 or 200
HTTPS validCheck browserValid cert, no warnings
Message round-tripSend WhatsApp messageBot replies
Allowlist worksMessage from unlisted numberNo 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

Troubleshooting

Webhook verification fails

Meta can't reach your server. Check:

  1. DNS: nslookup wabot.yourdomain.com returns your VPS IP
  2. Caddy running: sudo systemctl status caddy
  3. Port 443 open: sudo ufw allow 443/tcp
  4. 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

ItemCostNotes
Hetzner CX22 VPS$5/mo4GB RAM, 40GB disk
Domain$10/yrAny registrar
WhatsApp Business APIFree tier1,000 free conversations/month
CaddyFreeAuto-HTTPS
Total~$6/mo

This is the free version. The full Operator Playbook Pack includes multi-language support, AI vision integration, payment processing, and production hardening patterns.

Get the Full Pack →