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 JavaTenantConfig.encrypt, matching howtenant.db_passis stored.
Part 1: Database¶
Multi-tenant note: the single global
wa_servicerole + single-DB migration path below is the legacy single-tenant flow. Under multi-tenancy each tenant has its own DB (tlinq_<code>) carrying thetqwaschema (applied as part of that tenant's migrations), and the WA service uses a per-tenanttqwa-only role created byscripts/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¶
As tlinq user (normal migration path)¶
\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
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¶
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
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¶
- Go to Meta App Dashboard → WhatsApp → Configuration
- Webhook URL:
https://wadev.perunapps.com/webhook/<app_ref>— the per-app path matching theapp_refyou used in thewa_meta_approw (e.g./webhook/perun). - Verify token: the
webhook_verify_tokenfrom that tenant'swa_meta_approw (NOT a single global token — each app has its own). - 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. - Subscribe to field:
messages(covers inbound messages + delivery statuses)
Each tenant with its own Meta app repeats this with its own
app_refand verify token. Tenants on the shared app (use_shared_meta_app=true) share one app but still get distinctapp_refwebhook 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:
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:
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):
Verification Checklist¶
- [ ]
curl http://localhost:8001/healthon target host returns{"status":"ok","service":"tqwhatsapp"} - [ ]
curl https://wadev.perunapps.com/healthfrom outside returns OK - [ ] Meta webhook verification succeeded in App Dashboard (green checkmark)
- [ ] Internal API reachable:
dispatch-dataendpoint returns JSON from TQPro host - [ ]
wa_serviceDB user can querytqwatables - [ ]
wa_serviceDB user cannot queryntstables - [ ]
broadcast.wa.provider=TWILIO— existing broadcasts still work (no regression) - [ ] TeamCity agent can
ssh wa-devwithout password prompt - [ ]
./gradlew :tqwhatsapp:deploy -PdeployHost=wa-dev -PdeployUser=ubuntusucceeds - [ ] WABA subscribed via
subscribed_appsAPI (Part 5b) - [ ] AI CSR: send "Hi" → AI responds (not silent)
- [ ] AI CSR: ask for staycation → product options shown
- [ ] AI CSR: handoff → owner receives
lead_notificationtemplate - [ ] AI CSR: swipe-reply to product → quote context resolved in logs
- [ ]
ai-csr-prompt.txtpresent at/opt/tqwhatsapp/ai-csr-prompt.txt - [ ] Startup log shows "Configuration validated" and "Loaded AI CSR system prompt"
Next Steps¶
Once all checks pass:
- Test Meta broadcast with 10-20 warm contacts: set
broadcast.wa.provider=METAtemporarily - Verify full cycle: dispatch → Meta send → webhook → status update → Java UI
- When satisfied, set
broadcast.wa.provider=METApermanently - To enable AI CSR: set
AI_CSR_ENABLED=truein.env, restart service