iachat/META-WEBHOOK-PROXY.md

6.8 KiB
Raw Permalink Blame History

Meta Webhook Proxy

Problem

Some VPS providers silently drop inbound TCP connections from Meta's webhook servers (AS32934) due to overzealous DDoS protection. This causes 1520% WhatsApp message loss. A reverse proxy on a clean provider (e.g., DigitalOcean) eliminates the drops completely.

Meta (WhatsApp) → proxy.example.com (clean provider) → your Chatwoot instance

Architecture

The proxy is a single nginx server that routes requests based on the first path segment:

https://proxy.example.com/<upstream_host>/webhooks/whatsapp/%2B<phone>

This is the URL you configure in Meta's App Dashboard as the webhook callback URL. The proxy extracts <upstream_host>, checks it against an allowlist, and forwards the request to https://<upstream_host>/webhooks/whatsapp/%2B<phone>.

Multi-tenant

One proxy serves multiple Chatwoot instances. Each upstream is identified by its domain in the URL path — no separate config per tenant beyond adding the host to the allowlist.

Setup

Prerequisites

  • A server (Ubuntu 22.04/24.04) on a provider with clean Meta connectivity (DigitalOcean, AWS, etc.)
  • A DNS A record pointing your proxy domain to the server IP (e.g., proxy.example.com → 1.2.3.4)
  • SSH root access to the server

1. Install nginx and certbot

ssh root@proxy.example.com

apt-get update
apt-get install -y nginx certbot python3-certbot-nginx

2. Create a temporary HTTP-only config

Certbot needs nginx running to perform the ACME challenge, but the full config references SSL certs that don't exist yet. Start with an HTTP-only config:

cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
server {
    listen 80;
    server_name proxy.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 404;
    }
}
EOF

Enable the site and reload:

rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/cw-proxy /etc/nginx/sites-enabled/cw-proxy
nginx -t && systemctl reload nginx

3. Obtain the SSL certificate

certbot certonly --webroot -w /var/www/html -d proxy.example.com \
  --non-interactive --agree-tos -m your-email@example.com

Certbot installs a systemd timer that auto-renews the certificate before it expires.

4. Deploy the full proxy config

Replace the temporary config with the full proxy configuration:

cat > /etc/nginx/sites-available/cw-proxy << 'EOF'
# Allowlist of upstream Chatwoot hosts
# Add new hosts here to enable proxying
map $upstream_host $upstream_allowed {
    default                    0;
    chatwoot.example.com       1;
    # chatwoot.other.com       1;
}

server {
    listen 80;
    server_name proxy.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name proxy.example.com;

    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    ssl_certificate /etc/letsencrypt/live/proxy.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/proxy.example.com/privkey.pem;

    # Extract upstream host from first path segment, proxy the rest
    location ~ ^/([^/]+)(/.*)$ {
        set $upstream_host $1;
        set $upstream_path $2;

        # Reject hosts not in the allowlist
        if ($upstream_allowed = 0) {
            return 403;
        }

        proxy_pass https://$upstream_host$upstream_path$is_args$args;
        proxy_set_header Host $upstream_host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_ssl_server_name on;
    }

    location / {
        return 404;
    }
}
EOF

Test and reload:

nginx -t && systemctl reload nginx

5. Verify

# Root path → 404
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/
# Expected: 404

# Unknown host → 403
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/unknown.host/webhooks/whatsapp/test
# Expected: 403

# Allowed host → proxied (502 if upstream is unreachable, 200 if live)
curl -s -o /dev/null -w "%{http_code}" https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/test
# Expected: 502 or 200

6. Configure Meta webhook URL

In the Meta App Dashboard, set the webhook callback URL to:

https://proxy.example.com/<your-chatwoot-domain>/webhooks/whatsapp/%2B<phone>

For example, if your Chatwoot is at chatwoot.example.com and the phone number is +5511999999999:

https://proxy.example.com/chatwoot.example.com/webhooks/whatsapp/%2B5511999999999

Meta will send both verification (GET) and delivery (POST) requests to this URL. The proxy passes them through transparently.

Adding a new upstream

  1. SSH into the proxy server
  2. Edit /etc/nginx/sites-available/cw-proxy
  3. Add the new host to the map block:
    map $upstream_host $upstream_allowed {
        default                    0;
        chatwoot.example.com       1;
        chatwoot.newclient.com     1;   # ← add this line
    }
    
  4. Test and reload:
    nginx -t && systemctl reload nginx
    
  5. Set the Meta webhook callback URL for the new instance to:
    https://proxy.example.com/chatwoot.newclient.com/webhooks/whatsapp/%2B<phone>
    

Removing an upstream

  1. Remove or comment out the host from the map block
  2. nginx -t && systemctl reload nginx
  3. Update the Meta webhook callback URL to point directly at the Chatwoot instance (or to a different proxy)

Key nginx directives

Directive Purpose
map $upstream_host $upstream_allowed Allowlist of permitted upstream hosts. Only hosts set to 1 are proxied; all others get 403.
proxy_ssl_server_name on Enables SNI so the TLS handshake uses the correct hostname for the upstream's certificate.
resolver 1.1.1.1 8.8.8.8 valid=300s Required because proxy_pass uses a variable ($upstream_host), so nginx cannot resolve DNS at config load time. Uses Cloudflare and Google DNS.
proxy_set_header Host $upstream_host Sets the Host header to the upstream domain so reverse proxies (Traefik, etc.) route correctly.

Failure modes

All recoverable — Meta retries with exponential backoff for up to 36 hours:

Failure What happens Recovery
Proxy down Connection refused Meta retries
Upstream down 502 Bad Gateway Meta retries
SSL expired TLS handshake error Meta retries

Important notes

  • Do not rate-limit. Meta sends webhook deliveries from many IPs in AS32934. Bursts of 10+ requests per second are normal.
  • SSL auto-renewal is handled by the certbot systemd timer. Verify with systemctl status certbot.timer.
  • The %2B in the URL is the URL-encoded + sign for the phone number's country code.