Blog/Tutorials

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.

by Shakil
How to Build a Scalable Multi-Tenant WhatsApp API System for SaaS

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.