One token per connected number: how to map tenants to numbers, isolate credentials, route webhooks, and rotate safely.
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:
402 plan_feature_locked while the others keep working.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.
If a token is exposed, reconnect the number: that issues a new token and invalidates the old one immediately.
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.
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.
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",
)
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
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);
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.
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:
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. 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:
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.