GhalaGhalaHelp Center
Back to ghala.ioghala.ioSign in
  • Getting Started
    • Ghala Documentation
    • Send Your First WhatsApp Message via API
    • Receiving Events with Webhooks
  • Commerce
    • Start Selling on WhatsApp
    • Set Up the AI Sales Agent
    • Get Paid with Snippe
  • Ai Automation
    • Configuring AI Auto-Reply for WhatsApp
    • Setting Up Human Handover Protocol
  • Campaigns Messaging
    • WhatsApp Message Templates: Complete Guide
    • Bulk WhatsApp Messaging: Complete Campaign Guide
  • Contacts Crm
    • WhatsApp Contact Management Guide
  • Best Practices
    • WhatsApp Customer Support Best Practices
    • Message Template Best Practices
  • Api Reference
    • Ghala Developer API Reference
    • Ghala API vs Meta Cloud API
    • Supported Capabilities
    • Multiple Numbers and Multi-Tenant Platforms
    • Errors and Retries
    • Limits and Quotas
    • Authentication and Access Tokens
    • Connecting and Onboarding a Number
    • Templates and Media
    • Developer Tooling
    • Versioning and Changelog

Products

  • Ghala
  • Sarufi
  • Snippe
  • Sema

Explore

  • Use Cases
  • Pricing
  • Ghala Academy
  • Blog

Developers

  • Docs
  • API Reference
  • API Quickstart
  • Webhooks Guide

Contact

  • SkyCity Mall, 9th Floor, Dar es Salaam, Tanzania
  • info@ghala.io
  • +255 699 920 009
© 2026 Neurotech Company LimitedTerms of ServicePrivacy PolicySitemap
  1. Help Center
  2. Api Reference
  3. Multiple Numbers and Multi-Tenant Platforms

Multiple Numbers and Multi-Tenant Platforms

v2

One token per connected number: how to map tenants to numbers, isolate credentials, route webhooks, and rotate safely.

The one rule everything follows

Each connected number has its own access token, and that token authenticates exactly one number.

There is no account-level key, no team-level key, and no way to send from number B with number A's token. If you manage five numbers you hold five tokens. If you are a platform serving fifty client businesses, you hold fifty.

This is the single fact that decides your architecture, so it is worth stating what follows from it:

  • Your backend needs a lookup from whatever you call a tenant to that tenant's token, resolved per request.
  • A leaked token exposes exactly one number, which is the point.
  • Rotating one number's token does not disturb any other.
  • Each number's team is billed and entitled separately: every team must independently have the API access entitlement, or that number's calls return 402 plan_feature_locked while the others keep working.

Tokens must stay server-side

The token Ghala gives you is Meta-issued and is the same secret Ghala uses to send on your behalf. Anyone holding it can message that number's customers.

  • Never ship it in browser JavaScript, a mobile app bundle, or a native client. There is no browser-safe, scope-limited variant.
  • Never commit it. Use a secret manager, or environment variables at minimum.
  • Your customers' end users must never receive a token, directly or via an API of yours that proxies it back.
  • If your product lets clients see their own integration settings, show the masked preview the dashboard shows, not the secret.

If a token is exposed, reconnect the number: that issues a new token and invalidates the old one immediately.

Mapping tenant to number to token

Store the mapping, store a reference to the secret, and resolve the secret at call time. The table you want looks like this:

Column Example Why
tenant_id acme Your own identifier
phone_number 255712345678 Human-recognisable, and what you send to from
token_ref vault://ghala/acme/access-token A pointer, never the secret itself
webhook_secret_ref vault://ghala/acme/whsec The signing secret for this number's subscription
status active So a disconnected tenant fails loudly, not mysteriously

Keeping token_ref rather than the token means a database dump is not a credential dump, and rotation is a write to one place.

JavaScript

import { setTimeout as sleep } from "node:timers/promises";

/** Tenant -> Ghala credentials. In production this is your database plus a
 *  secret manager; the tokens themselves never live in application memory
 *  longer than a request needs them. */
async function loadTenant(tenantId) {
  const row = await db.tenants.findOne({ tenant_id: tenantId });
  if (!row) throw new Error(`Unknown tenant: ${tenantId}`);
  if (row.status !== "active") throw new Error(`Tenant not active: ${tenantId}`);
  return row;
}

/** Resolved per call and never cached in a module-level variable, so a
 *  rotation takes effect on the next request rather than the next deploy. */
async function accessTokenFor(tenantId) {
  const { token_ref } = await loadTenant(tenantId);
  return secrets.read(token_ref);
}

const GHALA_BASE = "https://v2.ghala.io/api/v2";

export async function sendMessage(tenantId, body, { idempotencyKey } = {}) {
  const token = await accessTokenFor(tenantId);

  const headers = {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  };
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;

  for (let attempt = 0; ; attempt++) {
    const resp = await fetch(`${GHALA_BASE}/messages`, {
      method: "POST",
      headers,
      body: JSON.stringify(body),
    });

    if (resp.ok) return resp.json();

    const error = await resp.json().catch(() => ({}));

    // 429 and 5xx are transient. Everything else is ours to fix.
    const retryable = resp.status === 429 || resp.status >= 500;
    if (!retryable || attempt >= 4) {
      throw Object.assign(new Error(error.message ?? resp.statusText), {
        status: resp.status,
        code: error.code,
        tenantId,
      });
    }

    await sleep(2 ** attempt * 500);
  }
}

Calling it stays free of credentials entirely:

await sendMessage(
  "acme",
  {
    to: "255712345678",
    type: "text",
    text: "Asante! Oda yako #1042 imethibitishwa.",
  },
  { idempotencyKey: "acme-order-1042-confirmation" },
);

Note the idempotency key is namespaced by tenant. Keys are scoped to the number they were used against, but prefixing keeps them unambiguous in your own logs and safe if you ever consolidate tenants.

Python

import os
import time

import requests

GHALA_BASE = "https://v2.ghala.io/api/v2"


def load_tenant(tenant_id):
    """Tenant -> Ghala credentials. Your database plus a secret manager."""
    row = db.tenants.find_one(tenant_id=tenant_id)
    if row is None:
        raise LookupError(f"Unknown tenant: {tenant_id}")
    if row["status"] != "active":
        raise LookupError(f"Tenant not active: {tenant_id}")
    return row


def access_token_for(tenant_id):
    """Resolved per call, so a rotation takes effect on the next request."""
    return secrets.read(load_tenant(tenant_id)["token_ref"])


class GhalaError(Exception):
    def __init__(self, message, status, code, tenant_id):
        super().__init__(message)
        self.status = status
        self.code = code
        self.tenant_id = tenant_id


def send_message(tenant_id, body, idempotency_key=None):
    headers = {"Authorization": f"Bearer {access_token_for(tenant_id)}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(5):
        resp = requests.post(f"{GHALA_BASE}/messages", headers=headers, json=body)

        if resp.ok:
            return resp.json()

        error = {}
        try:
            error = resp.json()
        except ValueError:
            pass

        # 429 and 5xx are transient. Everything else is ours to fix.
        retryable = resp.status_code == 429 or resp.status_code >= 500
        if not retryable or attempt == 4:
            raise GhalaError(
                error.get("message", resp.reason),
                resp.status_code,
                error.get("code"),
                tenant_id,
            )

        time.sleep(2**attempt * 0.5)
send_message(
    "acme",
    {
        "to": "255712345678",
        "type": "text",
        "text": "Asante! Oda yako #1042 imethibitishwa.",
    },
    idempotency_key="acme-order-1042-confirmation",
)

Routing webhooks from several numbers

One endpoint can receive events from many numbers, but there is a catch that decides the design: each event subscription has its own signing secret. If every number posts to the same URL, you cannot verify a delivery until you know which tenant it belongs to, and you cannot know which tenant it belongs to until you have verified it.

Trying each tenant's secret in turn is not an answer. It is linear in tenant count on every request, and it means an attacker can have you brute-force your own key list.

Register a distinct path per tenant instead. The URL carries the identity, so the secret to verify with is known before you touch the body:

https://you.example/ghala/acme/webhook
https://you.example/ghala/bakari-ltd/webhook
https://you.example/ghala/zawadi/webhook

JavaScript

import crypto from "node:crypto";
import express from "express";

const MAX_SKEW_SECONDS = 300;

function isValid(rawBody, signature, timestamp, secret) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) return false;

  const hmac = crypto.createHmac("sha256", secret);
  hmac.update(`${timestamp}.`);
  hmac.update(rawBody); // the exact bytes, still a Buffer
  const expected = Buffer.from(hmac.digest("hex"), "hex");
  const received = Buffer.from(
    String(signature ?? "").replace(/^sha256=/, ""),
    "hex",
  );

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}

const app = express();

// The tenant is in the path, so the right secret is known before the body
// is trusted. express.raw, not express.json: a parsed body cannot be verified.
app.post(
  "/ghala/:tenantId/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const { tenantId } = req.params;

    let secret;
    try {
      const tenant = await loadTenant(tenantId);
      secret = await secrets.read(tenant.webhook_secret_ref);
    } catch {
      return res.sendStatus(404);
    }

    const valid = isValid(
      req.body,
      req.get("X-Ghala-Signature"),
      req.get("X-Ghala-Timestamp"),
      secret,
    );
    if (!valid) return res.sendStatus(401);

    res.sendStatus(200); // acknowledge first, process after

    // Delivery is at-least-once: the same event can arrive more than once.
    // The delivery id is stable across retries, so dedupe on it - scoped by
    // tenant, so two tenants can never collide.
    const deliveryId = req.get("X-Ghala-Delivery");
    if (await alreadyHandled(tenantId, deliveryId)) return;

    await handle(tenantId, req.get("X-Ghala-Event"), JSON.parse(req.body));
  },
);

app.listen(3000);

Python

import hashlib
import hmac
import time

from flask import Flask, request, abort

MAX_SKEW_SECONDS = 300

app = Flask(__name__)


@app.post("/ghala/<tenant_id>/webhook")
def ghala_webhook(tenant_id):
    try:
        tenant = load_tenant(tenant_id)
        secret = secrets.read(tenant["webhook_secret_ref"]).encode()
    except LookupError:
        abort(404)

    raw = request.get_data()  # the exact bytes
    timestamp = request.headers.get("X-Ghala-Timestamp", "")
    signature = request.headers.get("X-Ghala-Signature", "")

    try:
        if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
            abort(401)
    except ValueError:
        abort(401)

    expected = hmac.new(
        secret, f"{timestamp}.".encode() + raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
        abort(401)

    # At-least-once delivery; the delivery id is stable across retries.
    delivery_id = request.headers.get("X-Ghala-Delivery")
    if not already_handled(tenant_id, delivery_id):
        handle(tenant_id, request.headers.get("X-Ghala-Event"), request.get_json())

    return "", 200

Deduplication keys should be (tenant_id, delivery_id), not delivery_id alone. Delivery ids are unique in practice, but scoping by tenant means a bug in one tenant's pipeline can never silently swallow another tenant's event.

Deliveries are also unordered. Sequence with timestamps in the payload, never with arrival order.

Onboarding additional client numbers

Connecting a number is dashboard work; there is no API for it. For a platform, that means onboarding has a human step, and the shape that works is:

  1. The client signs up for Ghala and lands in their own team.
  2. They connect their number through Embedded Signup. See Connecting and Onboarding a Number.
  3. They confirm their team's plan includes API access.
  4. They copy the number's access token from Developer → Credentials and give it to you through whatever secure channel you already use for credentials.
  5. They register your per-tenant endpoint under Developer → Events, and give you the signing secret, which is shown exactly once.
  6. You store both as secret references and flip the tenant to active.

Numbers may live in different WABAs and different Meta Business Portfolios; Ghala scopes on the connected number, so nothing about your integration changes. What does differ per team is the plan, and therefore the API entitlement.

If you are onboarding numbers at a scale where a manual step per client is untenable, that is the point at which Meta's own Embedded Signup and Business Management APIs are the right tool, and Ghala is not.

Isolation and rotation

Isolation. One secret per number, one secret per subscription, stored under separate references. Nothing should be able to read every tenant's secrets at once; scope your secret manager per tenant if it supports it. Log the tenant_id on every send and every delivery, never the token.

Rotation. A token is rotated by reconnecting the number, which issues a new one and invalidates the old immediately. There is no overlap window, so:

  1. Reconnect the number in the dashboard.
  2. Write the new token to the same secret reference.
  3. Because tokens are resolved per call rather than cached at boot, the next request picks it up. If you do cache, invalidate on write.

A 401 not_authenticated on a tenant that worked yesterday almost always means the number was reconnected and nobody updated the stored token.

Webhook secrets cannot be rotated in place. The secret is returned once at creation and is encrypted at rest afterwards. To rotate, register a second subscription with a new URL, verify it is receiving, then delete the old one.

A compromised token should be reconnected immediately; that is the revocation mechanism. Then audit what was sent from that number while it was exposed.

What's next

  • Authentication and Access Tokens — lifecycle, expiry, and auditability
  • Errors and Retries — what to do with each failure
  • Connecting and Onboarding a Number — the client-side steps
PreviousSupported CapabilitiesNextErrors and Retries

On this page

  • The one rule everything follows
  • Tokens must stay server-side
  • Mapping tenant to number to token
  • JavaScript
  • Python
  • Routing webhooks from several numbers
  • JavaScript
  • Python
  • Onboarding additional client numbers
  • Isolation and rotation
  • What's next