A token per connected number, sometimes shared: how to name the number, map tenants to numbers, isolate credentials, route webhooks, and rotate safely.
Every request acts as exactly one connected number, and the access token decides which.
Usually a number's token is its own. The exception: two numbers connected from the same WhatsApp Business Account can end up with the same token from Meta. Then the token alone does not say which number to act as, and you name it in the X-Phone-Number-Id header. When one token covers several numbers has the details.
There is no account-level key, no team-level key, and no way to send from number B with a token that number B does not hold. If you manage five numbers, you usually hold five tokens; numbers from one WhatsApp Business Account may share one. If you are a platform serving fifty client businesses, you hold a token for each.
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.Both endpoints, POST /api/v2/messages and GET /api/v2/templates, read X-Phone-Number-Id the same way. Its value is the number's WhatsApp phone number ID.
| The token is held by | X-Phone-Number-Id |
Result |
|---|---|---|
| One number | Not sent | Acts as that number |
| One number | That number's ID | Acts as that number |
| One number | Any other ID | 403 number_not_for_token |
| Several numbers | Not sent | 400 ambiguous_number, and nothing is sent |
| Several numbers | One of their IDs | Acts as that number |
| Several numbers | Any other ID | 403 number_not_for_token |
The header picks among the numbers that hold the token. It never reaches a number that does not, in your team or another. An empty header counts as not sent.
Without the header, a shared token gets this response, with the IDs it holds:
{
"code": "ambiguous_number",
"message": "this access token is held by more than one connected number; name the one to act as in the X-Phone-Number-Id header",
"phone_number_ids": ["106540352242922", "113269274970227"]
}
Finding the IDs. No endpoint lists your numbers. Each number's Phone Number ID is on Developer → Credentials, and the 400 ambiguous_number body lists every ID the token holds.
Only connected numbers count. Once a number is disconnected it no longer holds the token, so if one number is left the header becomes optional again.
The simplest rule: send X-Phone-Number-Id on every request. A token held by one number accepts its own ID, so the same code works whether a token is shared or not.
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 |
phone_number_id |
106540352242922 |
Sent as X-Phone-Number-Id, so a shared token acts as the right number |
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 credentialsFor(tenantId) {
const { token_ref, phone_number_id } = await loadTenant(tenantId);
return { token: await secrets.read(token_ref), phoneNumberId: phone_number_id };
}
const GHALA_BASE = "https://v2.ghala.io/api/v2";
export async function sendMessage(tenantId, body, { idempotencyKey } = {}) {
const { token, phoneNumberId } = await credentialsFor(tenantId);
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
// Picks the number when two numbers share the token.
"X-Phone-Number-Id": phoneNumberId,
};
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 credentials_for(tenant_id):
"""Resolved per call, so a rotation takes effect on the next request."""
tenant = load_tenant(tenant_id)
return secrets.read(tenant["token_ref"]), tenant["phone_number_id"]
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):
token, phone_number_id = credentials_for(tenant_id)
headers = {
"Authorization": f"Bearer {token}",
# Picks the number when two numbers share the token.
"X-Phone-Number-Id": phone_number_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 token, 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.