Skip to content

WhatsApp Service — First Deployment Guide

Step-by-step instructions for the initial manual deployment of the Python WhatsApp service (tqwhatsapp) on AWS, and preparation for future automated deployments via TeamCity.

Prerequisites

  • AWS EC2 instance with Ubuntu (same host as TQPro or separate)
  • Python 3.10+ installed
  • PostgreSQL accessible from the service host
  • DNS record pointing to the nginx reverse proxy:
  • Dev: wadev.perunapps.com
  • Production: waapi.peruntours.com
  • Meta Business App with WhatsApp product configured, test phone number assigned

Part 0: Multi-tenant model (read first)

As of the multi-tenant refactor, the service is no longer single-.env. The .env holds only service-level config; per-tenant Meta credentials, DB connections, and AI prompts are resolved at runtime from tqplatform.

Config tables (in tqplatform):

Table Holds Cardinality
wa_meta_app (new, migration 0002) per Meta app: access_token, app_secret, waba_id, verify_token, ai_csr_enabled, use_shared_meta_app, and app_ref (the webhook URL slug) 1 app → many phones; 1 tenant → many apps
wa_phone_routing (existing, extended) phone_number_id → tenant_id, plus app_ref and within-tenant label many phones → 1 tenant
tenant (existing) tenant DB connection (db_name/db_user/db_pass)

Webhook URL is per Meta app: https://wa.<domain>/webhook/<app_ref>. Each tenant's Meta app is registered in the Meta dashboard with its own app_ref path and verify token; inbound POSTs are HMAC-verified with that app's secret, then routed to the tenant by the payload's phone_number_id.

Outbound (Java → Python): the Java caller passes X-Tenant-ID (from RequestContext.tenantId); the service resolves that tenant's sending identity (app + phone) from the registry.

Secrets: Meta tokens in wa_meta_app are encrypted by the Java side (TenantConfig.encrypt, encrypted: prefix). The Python service decrypts them with the same TQPRO_ENCRYPTION_KEY (set in the service .env).

Encryption keys (two, distinct): - TQPRO_ENCRYPTION_KEY — decrypts platform-stored Meta secrets (shared with Java). - CONVERSATION_ENCRYPTION_KEY — encrypts message columns in each tenant's tqwa schema (Python-owned).

Per-tenant WA config — SQL (run on tqplatform)

After provisioning a tenant (so its tenant row exists) and creating its Meta app in the Meta dashboard:

-- One Meta app for the tenant. app_ref becomes the webhook URL slug.
-- For dev, plaintext secrets are accepted (TenantConfig.decrypt treats
-- non-'encrypted:'-prefixed values as passthrough). For prod, store the
-- 'encrypted:'-prefixed forms produced by the Java TenantConfig.encrypt.
INSERT INTO wa_meta_app (app_ref, tenant_id, meta_access_token, meta_app_secret,
                         meta_waba_id, webhook_verify_token, use_shared_meta_app,
                         ai_csr_enabled)
VALUES ('perun', '<tenant_id>', '<meta-access-token>', '<meta-app-secret>',
        '<waba-id>', '<verify-token>', false, true);

-- Map the tenant's phone(s) to it. Multiple rows = multiple phones/agents.
INSERT INTO wa_phone_routing (phone_number_id, tenant_id, app_ref, label)
VALUES ('<phone-number-id>', '<tenant_id>', 'perun', 'main');

Then reload the in-memory registry (no restart needed):

curl -s -X POST -H "Authorization: Bearer ${TQPRO_API_KEY}" \
    http://localhost:8001/internal/wa/refresh
# {"status":"refreshed","apps":N}

Confine the WA service to tqwa (per-tenant scoped role)

By default the per-tenant DB pool would connect with the tenant's main role (tenant.db_user), which can reach nts and every other schema. To restore the "WA service blocked from nts" isolation, provision a tqwa-only role per tenant with scripts/platform/wa-enable-tenant.sh:

# libpq env (PGHOST/PGUSER/PGPASSWORD or ~/.pgpass) must reach the tenant DB
# as a superuser (role creation) and the platform DB.
# Optionally auto-refresh the running service:
WA_SERVICE_URL=http://<wa-host>:8001 TQPRO_API_KEY=<key> \
    scripts/platform/wa-enable-tenant.sh perun

It creates wa_<code> granted USAGE + ALL on tqwa only (explicitly nothing on nts), verifies the role has no nts USAGE, and stores its credentials in tqplatform.wa_tenant_db (migration 0003). The registry then connects the WA service with this scoped role; if a tenant has no wa_tenant_db row the service falls back to the main role and logs a WARNING on startup so the gap is visible.

Prod note: the script stores the role password as plaintext (dev passthrough). For production, pass WA_DB_PASS='encrypted:<...>' produced by the Java TenantConfig.encrypt, matching how tenant.db_pass is stored.


Part 1: Database

Multi-tenant note: the single global wa_service role + single-DB migration path below is the legacy single-tenant flow. Under multi-tenancy each tenant has its own DB (tlinq_<code>) carrying the tqwa schema (applied as part of that tenant's migrations), and the WA service uses a per-tenant tqwa-only role created by scripts/platform/wa-enable-tenant.sh (see Part 0). The steps below are retained for the legacy single-tenant deployment only.

As postgres user (one-time only) — legacy single-tenant

sudo -u postgres psql
CREATE USER wa_service WITH PASSWORD '<strong-password>';

As tlinq user (normal migration path)

psql -U tlinq -d tlinq

\i config/db-changes/0062-broadcast-meta-support.sql
\i config/db-changes/0063-whatsapp-meta-tables.sql
\i config/db-changes/0064-ai-usage-log-csr-support.sql
\i config/db-changes/0065-wa-service-db-user.sql
\i config/db-changes/0066-message-template.sql
\i config/db-changes/0067-backfill-broadcast-templates.sql
\i config/db-changes/0068-broadcast-offer-context.sql
-- 0069 if exists
\i config/db-changes/0070-conversation-messages.sql
Follow with other scripts if needed.

Verify

-- Should show 5 tables
\dt tqwa.*

-- Should fail (wa_service blocked from nts)
SET ROLE wa_service;
SELECT * FROM nts.mkt_broadcast LIMIT 1;  -- ERROR: permission denied
RESET ROLE;

-- Should succeed
SET ROLE wa_service;
SELECT * FROM tqwa.whatsapp_templates LIMIT 1;
RESET ROLE;

Part 2: Target Host Setup

All commands run as the ubuntu user (the standard service user for all TQPro applications).

# 1. Create directory structure
sudo mkdir -p /opt/tqwhatsapp
sudo chown ubuntu:ubuntu /opt/tqwhatsapp

# 2. Create virtual environment
python3 -m venv /opt/tqwhatsapp/venv

# 3. Copy code (first time — manual scp from your workstation)
#    Run this FROM your local machine:
scp -r tqwhatsapp/app tqwhatsapp/requirements.txt tqwhatsapp/pyproject.toml \
    ubuntu@wa-dev:/opt/tqwhatsapp/

# 4. Install dependencies (on the target host)
/opt/tqwhatsapp/venv/bin/pip install -r /opt/tqwhatsapp/requirements.txt

Create .env file

nano /opt/tqwhatsapp/.env

Paste:

META_ACCESS_TOKEN=<meta-system-user-token>
META_PHONE_NUMBER_ID=<from-meta-dashboard>
META_WABA_ID=<from-meta-dashboard>
META_APP_SECRET=<from-meta-app-settings>
META_WEBHOOK_VERIFY_TOKEN=<pick-a-random-string>
META_GRAPH_API_VERSION=v25.0

TQPRO_API_URL=http://<tqpro-private-ip>:11080/tlinq-api
TQPRO_API_KEY=<generate-a-shared-secret>

DB_HOST=<postgres-private-ip>
DB_PORT=5432
DB_NAME=tlinq
DB_USER=wa_service
DB_PASSWORD=<password-from-part-1>

AI_CSR_ENABLED=true
CONVERSATION_ENCRYPTION_KEY=<run: openssl rand -hex 32>
DATA_RETENTION_YEARS=5

OWNER_EMAIL=owner@example.com
OWNER_WHATSAPP_NUMBER=+971XXXXXXXXX
chmod 600 /opt/tqwhatsapp/.env

Install systemd unit

sudo tee /etc/systemd/system/tqwhatsapp.service << 'EOF'
[Unit]
Description=TQPro WhatsApp Service
After=network.target postgresql.service

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/tqwhatsapp
ExecStart=/opt/tqwhatsapp/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8001 --proxy-headers --forwarded-allow-ips='*'
EnvironmentFile=/opt/tqwhatsapp/.env
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

Start the service

sudo systemctl daemon-reload
sudo systemctl enable tqwhatsapp
sudo systemctl start tqwhatsapp

# Verify
sudo systemctl status tqwhatsapp
curl http://localhost:8001/health
# Expected: {"status":"ok","service":"tqwhatsapp"}

Part 3: TQPro Java Configuration

On the TQPro host, edit config/properties.d/messaging.properties:

broadcast.wa.provider=TWILIO
whatsapp.python.service.url=http://<wa-service-private-ip>:8001
whatsapp.python.service.api-key=<same-value-as-TQPRO_API_KEY-in-env>

Restart TQPro and verify the internal API is reachable:

curl -X POST \
  -H "Authorization: Bearer <shared-secret>" \
  -H "Content-Type: application/json" \
  -d '{"broadcastId":1}' \
  http://localhost:11080/tlinq-api/marketing/internal/broadcast/dispatch-data

A JSON response (even an error about broadcast not found) confirms connectivity. A connection refused or timeout indicates a network/firewall issue.


Part 4: Nginx Reverse Proxy

Run on the existing nginx reverse proxy host.

Create site configuration

sudo tee /etc/nginx/sites-available/wadev.perunapps.com << 'EOF'
upstream wa_service {
    server <wa-service-private-ip>:8001;
}

server {
    listen 443 ssl http2;
    server_name wadev.perunapps.com;

    ssl_certificate     /etc/letsencrypt/live/wadev.perunapps.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/wadev.perunapps.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;

    # Meta Webhooks (public) — per-app path /webhook/<app_ref>.
    # Raw body must pass through unmodified for HMAC verification.
    location /webhook/ {
        proxy_pass http://wa_service;
        proxy_request_buffering on;
        proxy_http_version 1.1;
        proxy_set_header Content-Type $content_type;
    }

    # Internal API (Java → Python dispatch)
    location /api/ {
        proxy_pass http://wa_service;
    }

    # Health check
    location /health {
        proxy_pass http://wa_service;
        access_log off;
    }

    location / {
        return 404;
    }
}

server {
    listen 80;
    server_name wadev.perunapps.com;
    return 301 https://$host$request_uri;
}
EOF

Enable and activate

# SSL certificate (DNS must already point to this host)
sudo certbot certonly --nginx -d wadev.perunapps.com

# Enable site
sudo ln -s /etc/nginx/sites-available/wadev.perunapps.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

# Verify from outside
curl https://wadev.perunapps.com/health

Part 5: Register Webhook and Subscribe WABA

5a. Register Webhook URL

  1. Go to Meta App DashboardWhatsAppConfiguration
  2. Webhook URL: https://wadev.perunapps.com/webhook/<app_ref> — the per-app path matching the app_ref you used in the wa_meta_app row (e.g. /webhook/perun).
  3. Verify token: the webhook_verify_token from that tenant's wa_meta_app row (NOT a single global token — each app has its own).
  4. Click Verify and Save — Meta sends a GET challenge to /webhook/<app_ref>, the service looks up that app's verify token and responds with the challenge value.
  5. Subscribe to field: messages (covers inbound messages + delivery statuses)

Each tenant with its own Meta app repeats this with its own app_ref and verify token. Tenants on the shared app (use_shared_meta_app=true) share one app but still get distinct app_ref webhook paths.

5b. Subscribe App to WABA (CRITICAL)

The webhook registration alone is not enough. You must also subscribe the app to receive webhooks for the WABA. Without this step, webhook verification works but actual message webhooks never arrive.

source /opt/tqwhatsapp/.env
curl -s -X POST -H "Authorization: Bearer $META_ACCESS_TOKEN" \
  "https://graph.facebook.com/v25.0/$META_WABA_ID/subscribed_apps"

Expected response: {"success": true}

Verify subscription:

curl -s -H "Authorization: Bearer $META_ACCESS_TOKEN" \
  "https://graph.facebook.com/v25.0/$META_WABA_ID?fields=name,subscribed_apps"

If subscribed_apps is empty, the subscription failed — check the System User has admin access to the WABA.

5c. Verify End-to-End

Send a WhatsApp message to the business number and watch the logs:

sudo journalctl -u tqwhatsapp -f

You should see:

Webhook payload: entries=1
Webhook change: statuses=0, messages=1
Inbound message: type=text, from=...

If no webhook arrives, re-check: WABA subscription, webhook URL, and nginx proxy config.


Part 6: Prepare for Automated Deployment (TeamCity)

On the TeamCity build agent

Add an SSH config entry for the target host:

cat >> ~/.ssh/config << 'EOF'

Host wa-dev
    HostName <wa-service-private-ip-or-hostname>
    User ubuntu
    IdentityFile ~/.ssh/<aws-key-name>.pem
EOF

chmod 600 ~/.ssh/config

Test connectivity:

ssh wa-dev 'echo ok'

On the target host

The ubuntu user already owns /opt/tqwhatsapp and runs the service, so the TeamCity agent (connecting as ubuntu) has write access. Ensure ubuntu can restart the service without a password prompt:

echo "ubuntu ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart tqwhatsapp" \
    | sudo tee /etc/sudoers.d/tqwhatsapp-deploy

Test automated deploy

From the TeamCity agent (or your workstation with the same SSH config):

./gradlew :tqwhatsapp:deploy -PdeployHost=wa-dev -PdeployUser=ubuntu

Verification Checklist

  • [ ] curl http://localhost:8001/health on target host returns {"status":"ok","service":"tqwhatsapp"}
  • [ ] curl https://wadev.perunapps.com/health from outside returns OK
  • [ ] Meta webhook verification succeeded in App Dashboard (green checkmark)
  • [ ] Internal API reachable: dispatch-data endpoint returns JSON from TQPro host
  • [ ] wa_service DB user can query tqwa tables
  • [ ] wa_service DB user cannot query nts tables
  • [ ] broadcast.wa.provider=TWILIO — existing broadcasts still work (no regression)
  • [ ] TeamCity agent can ssh wa-dev without password prompt
  • [ ] ./gradlew :tqwhatsapp:deploy -PdeployHost=wa-dev -PdeployUser=ubuntu succeeds
  • [ ] WABA subscribed via subscribed_apps API (Part 5b)
  • [ ] AI CSR: send "Hi" → AI responds (not silent)
  • [ ] AI CSR: ask for staycation → product options shown
  • [ ] AI CSR: handoff → owner receives lead_notification template
  • [ ] AI CSR: swipe-reply to product → quote context resolved in logs
  • [ ] ai-csr-prompt.txt present at /opt/tqwhatsapp/ai-csr-prompt.txt
  • [ ] Startup log shows "Configuration validated" and "Loaded AI CSR system prompt"

Next Steps

Once all checks pass:

  1. Test Meta broadcast with 10-20 warm contacts: set broadcast.wa.provider=META temporarily
  2. Verify full cycle: dispatch → Meta send → webhook → status update → Java UI
  3. When satisfied, set broadcast.wa.provider=META permanently
  4. To enable AI CSR: set AI_CSR_ENABLED=true in .env, restart service