How to Build a Scalable Multi-Tenant WhatsApp API System for SaaS
Build a multi-tenant WhatsApp API system where each SaaS customer has their own isolated WhatsApp number, API key, and webhook. Full architecture guide with Python, PostgreSQL, and Rapiwa.
A multi-tenant WhatsApp API system lets SaaS products give each customer their own dedicated WhatsApp number, API key, and message routing — all backed by Rapiwa's infrastructure. Each tenant is fully isolated: their messages don't mix with other tenants, their webhooks are routed correctly, and their usage is tracked separately. Rapiwa costs $5/month per number with no per-message fees.
What Is a Multi-Tenant WhatsApp System?
Multi-tenancy means one system serves multiple customers (tenants), each with their own isolated configuration:
Your SaaS Product
├── Tenant A (Company ABC) → WhatsApp Number A → API Key A → Webhooks A
├── Tenant B (Company XYZ) → WhatsApp Number B → API Key B → Webhooks B
└── Tenant C (Startup 123) → WhatsApp Number C → API Key C → Webhooks C
Each tenant:
- Has their own WhatsApp number (connected via Rapiwa QR scan)
- Has their own Rapiwa API key
- Receives their own incoming message webhooks
- Has isolated usage tracking and billing
Architecture Overview
[Tenant A Dashboard]
↓ (configure settings)
[Your SaaS Backend]
↓ (store tenant config)
[PostgreSQL: tenants table]
↓ (on message trigger)
[Message Router Service]
↓ (lookup tenant config)
[Rapiwa API] ← tenant's API key
↓
[Tenant A's WhatsApp Number]
Step 1: Database Schema
-- Tenants table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_name VARCHAR(200) NOT NULL,
plan VARCHAR(50) DEFAULT 'starter',
rapiwa_api_key VARCHAR(200), -- Tenant's Rapiwa API key
whatsapp_number VARCHAR(20), -- Tenant's WhatsApp number
webhook_url VARCHAR(500), -- Where to forward incoming messages
webhook_secret VARCHAR(100), -- For webhook signature verification
messages_sent INT DEFAULT 0,
messages_received INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
active BOOLEAN DEFAULT TRUE
);
-- Message log for billing and debugging
CREATE TABLE message_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
direction VARCHAR(10) NOT NULL, -- 'outbound' or 'inbound'
from_number VARCHAR(20) NOT NULL,
to_number VARCHAR(20) NOT NULL,
message_preview VARCHAR(100), -- First 100 chars
rapiwa_message_id VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, failed, received
created_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX idx_message_log_tenant ON message_log(tenant_id);
CREATE INDEX idx_message_log_created ON message_log(created_at DESC);
CREATE INDEX idx_tenants_number ON tenants(whatsapp_number);
Step 2: Message Router Service
The core component — routes messages to the correct Rapiwa API key:
# message_router.py
import requests
import psycopg2
from typing import Optional
from functools import lru_cache
import uuid
class MessageRouter:
def __init__(self, db_connection_string: str):
self.db_conn = psycopg2.connect(db_connection_string)
def get_tenant_config(self, tenant_id: str) -> Optional[dict]:
"""Fetch tenant configuration from database."""
cursor = self.db_conn.cursor()
cursor.execute(
"SELECT id, rapiwa_api_key, whatsapp_number, active FROM tenants WHERE id = %s",
[tenant_id]
)
row = cursor.fetchone()
if not row:
return None
return {'id': row[0], 'api_key': row[1], 'number': row[2], 'active': row[3]}
def send_message(self, tenant_id: str, recipient: str, message: str) -> dict:
"""
Send a WhatsApp message on behalf of a tenant.
Uses the tenant's own Rapiwa API key.
"""
tenant = self.get_tenant_config(tenant_id)
if not tenant:
raise ValueError(f"Tenant {tenant_id} not found")
if not tenant['active']:
raise PermissionError(f"Tenant {tenant_id} is inactive")
if not tenant['api_key']:
raise ValueError(f"Tenant {tenant_id} has no API key configured")
# Send via Rapiwa using tenant's API key
response = requests.post(
'https://app.rapiwa.com/send-message',
headers={'Authorization': f"Bearer {tenant['api_key']}"},
json={'number': recipient, 'message': message},
timeout=15
)
result = response.json()
# Log the message
self._log_message(
tenant_id=tenant_id,
direction='outbound',
from_number=tenant['number'],
to_number=recipient,
message_preview=message[:100],
rapiwa_message_id=result.get('messageId'),
status='sent' if result.get('status') == 'success' else 'failed'
)
# Update usage counter
self.db_conn.cursor().execute(
"UPDATE tenants SET messages_sent = messages_sent + 1 WHERE id = %s",
[tenant_id]
)
self.db_conn.commit()
return result
def _log_message(self, **kwargs) -> None:
cursor = self.db_conn.cursor()
cursor.execute("""
INSERT INTO message_log
(tenant_id, direction, from_number, to_number, message_preview,
rapiwa_message_id, status)
VALUES (%(tenant_id)s, %(direction)s, %(from_number)s, %(to_number)s,
%(message_preview)s, %(rapiwa_message_id)s, %(status)s)
""", kwargs)
self.db_conn.commit()
Step 3: Tenant-Specific API Endpoint
Your SaaS exposes an API that tenants use to send messages:
# api.py
from flask import Flask, request, jsonify, g
import jwt
app = Flask(__name__)
router = MessageRouter(os.environ['DATABASE_URL'])
def authenticate_tenant(token: str) -> Optional[str]:
"""Validate JWT token and return tenant_id."""
try:
payload = jwt.decode(token, os.environ['JWT_SECRET'], algorithms=['HS256'])
return payload.get('tenant_id')
except jwt.InvalidTokenError:
return None
@app.route('/api/v1/send-message', methods=['POST'])
def send_message():
"""Tenant-facing API endpoint to send WhatsApp messages."""
# Authenticate
auth_header = request.headers.get('Authorization', '')
token = auth_header.replace('Bearer ', '')
tenant_id = authenticate_tenant(token)
if not tenant_id:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
recipient = data.get('number')
message = data.get('message')
if not recipient or not message:
return jsonify({'error': 'Missing required fields: number, message'}), 400
try:
result = router.send_message(tenant_id, recipient, message)
return jsonify(result)
except PermissionError as e:
return jsonify({'error': str(e)}), 403
except ValueError as e:
return jsonify({'error': str(e)}), 400
Step 4: Incoming Webhook Router
Register a single Rapiwa webhook URL per tenant, routing to their configured webhook:
@app.route('/webhooks/whatsapp/<tenant_id>', methods=['POST'])
def receive_whatsapp(tenant_id: str):
"""
Rapiwa calls this webhook per tenant.
Each tenant registers their own URL:
https://yourapp.com/webhooks/whatsapp/{tenant_id}
"""
tenant = router.get_tenant_config(tenant_id)
if not tenant:
return jsonify({'error': 'Tenant not found'}), 404
payload = request.get_json()
# Log the inbound message
data = payload.get('data', {})
router._log_message(
tenant_id=tenant_id,
direction='inbound',
from_number=data.get('from', ''),
to_number=tenant['number'],
message_preview=data.get('message', '')[:100],
rapiwa_message_id=data.get('messageId'),
status='received'
)
# Forward to tenant's webhook URL
if tenant.get('webhook_url'):
try:
requests.post(
tenant['webhook_url'],
json=payload,
headers={'X-Tenant-ID': tenant_id},
timeout=5
)
except Exception as e:
# Log but don't fail
app.logger.error(f"Failed to forward to tenant webhook: {e}")
return jsonify({'status': 'ok'})
Step 5: Tenant Dashboard — Connect WhatsApp
In your SaaS dashboard, guide tenants through connecting their WhatsApp:
@app.route('/api/v1/setup/connect-whatsapp', methods=['POST'])
def connect_whatsapp():
"""
Instructions for tenants:
1. Log in to their Rapiwa account at rapiwa.com
2. Connect their WhatsApp number via QR code
3. Copy their API key from Dashboard → API Keys
4. Enter the API key in your SaaS settings
"""
tenant_id = authenticate_tenant(request.headers.get('Authorization').replace('Bearer ', ''))
data = request.get_json()
api_key = data.get('rapiwa_api_key')
whatsapp_number = data.get('whatsapp_number')
# Validate the API key by sending a test message to the number
test_result = requests.post(
'https://app.rapiwa.com/send-message',
headers={'Authorization': f'Bearer {api_key}'},
json={
'number': whatsapp_number,
'message': '✅ Your WhatsApp is now connected to [YourSaaS]!'
}
).json()
if test_result.get('status') != 'success':
return jsonify({'error': 'Invalid API key or number — check your Rapiwa dashboard'}), 400
# Save to database
db.execute(
"UPDATE tenants SET rapiwa_api_key = %s, whatsapp_number = %s WHERE id = %s",
[api_key, whatsapp_number, tenant_id]
)
# Register webhook with Rapiwa
webhook_url = f"https://yourapp.com/webhooks/whatsapp/{tenant_id}"
# (Rapiwa webhook registration via API or manually in dashboard)
return jsonify({
'status': 'connected',
'whatsapp_number': whatsapp_number,
'webhook_url': webhook_url
})
Step 6: Usage Tracking and Billing
def get_tenant_usage(tenant_id: str, month: str) -> dict:
"""Get usage stats for billing calculation."""
stats = db.fetchone("""
SELECT
COUNT(*) FILTER (WHERE direction = 'outbound') as messages_sent,
COUNT(*) FILTER (WHERE direction = 'inbound') as messages_received
FROM message_log
WHERE tenant_id = %s
AND DATE_TRUNC('month', created_at) = %s
""", [tenant_id, month])
return {
'tenant_id': tenant_id,
'period': month,
'messages_sent': stats['messages_sent'],
'messages_received': stats['messages_received'],
'rapiwa_cost': 5.00, # Flat $5/month per number
'your_charge': calculate_tenant_price(stats['messages_sent'])
}
def calculate_tenant_price(messages_sent: int) -> float:
"""Your pricing model — example tiered pricing."""
if messages_sent <= 1000:
return 29.00 # Starter tier
elif messages_sent <= 5000:
return 79.00 # Growth tier
else:
return 199.00 # Scale tier
Test the Multi-Tenant Setup
# Test sending as Tenant A
curl -X POST https://yourapp.com/api/v1/send-message \
-H "Authorization: Bearer TENANT_A_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"number": "8801234567890",
"message": "Hello from Tenant A system!"
}'
FAQ
Does each tenant need their own Rapiwa account? Each tenant needs their own Rapiwa account and API key. Your SaaS stores and manages these keys, but each tenant connects their own WhatsApp number directly.
How many tenants can one Rapiwa Enterprise account support? Rapiwa Enterprise supports 20+ numbers. Contact Rapiwa at rapiwa.com for larger deployments.
Does Rapiwa charge per message for multi-tenant systems? No. Rapiwa charges $5/month per number (per tenant), flat, with no per-message fees. Your SaaS bills tenants according to your own pricing model.
How do I isolate tenant data to prevent cross-tenant message access?
The tenant_id foreign key in message_log ensures isolation. Never query messages without a WHERE tenant_id = ? clause. Use row-level security in PostgreSQL for extra protection.
Can I scale beyond 100 tenants? Yes. The architecture scales horizontally — add read replicas for the database, use Redis for API key caching, and run multiple router service instances behind a load balancer.
