If you build a WhatsApp product for other businesses, every customer's events arrive at the same place: one Meta app has one webhook callback URL and one app secret, so all of your tenants share an endpoint. Multi-tenancy is something you add on your side. Resolve the tenant from the IDs Meta sends, metadata.phone_number_id first and the WABA ID in entry[].id when that's all there is. Park anything you can't match instead of guessing, process each change on its own, and scope every query by tenant from then on. This post covers the routing, plus the onboarding and offboarding events that keep your tenant table true. The snippets are small, self-contained versions of the patterns, and each was run against the cases shown. One endpoint, everyone's events When a business connects through your app, its WhatsApp Business Account (WABA) is subscribed to your app, and from then on its events are sent to your app's callback URL. A webhook looks like this: { "object": "whatsapp_business_account", "entry": [ { "id": "", "changes": [ { "field": "messages", "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "15550100001", "phone_number_id": "" }, "messages": [{ "id": "wamid...", "from": "15550123456", "type": "text", "text": { "body": "Hi" } }] } } ] } ] } Enter fullscreen mode Exit fullscreen mode Three facts shape everything else: entry and changes are arrays. Meta's webhook endpoint guide says requests are "aggregated and sent in a batch with a maximum of 1000 updates", and also that "batching cannot be guaranteed". Loop over both, every time. The signature isn't per tenant. The same guide says the X-Hub-Signature-256 hash is calculated with "your app secret as the secret key". You verify every tenant's webhooks with one secret, before you know whose they are. (We covered verification in handling webhooks in Node.js.) Retries repeat events. Failed deliveries are "retried immediately, then a few more times with decreasing frequency over the next 7 days", and Meta's webhooks overview warns that "These retries can result in duplicate webhook notifications". Resolve the tenant from Meta's IDs, and nothing else Every change has to become a tenant ID before any business logic runs. The order matters: metadata.phone_number_id, when the change has one. Messages, statuses and calls carry it, and it identifies exactly one business number. The WABA ID, for account-level changes that have no phone number ID. That's usually entry[].id, but Meta's docs only state it for some events, and account_update names the account in value.waba_info.waba_id, with a different ID in entry.id. Log a few live payloads and check yours. Nothing else. Not the customer's phone number, not a display name, and never a tenant ID from a query string you added to the callback URL. Account-level changes are the awkward part. The phone number quality update (phone_number_quality_update), which also reports messaging-limit changes, carries the WABA ID and a display_phone_number directly on value, but no metadata and no phone_number_id. If a WABA holds more than one of your numbers, narrow by that display number. If it still isn't one tenant, stop: // Map one webhook change to the tenant that owns it, using only IDs Meta sent. export type NumberRecord = { tenantId: string; wabaId: string; phoneNumberId: string; displayPhone: string; active: boolean }; export type Resolution = { tenantId: string; phoneNumberId: string | null } | { unknown: string }; const digits = (value: string) => value.replace(/\D/g, ""); export const resolveTenant = ( numbers: NumberRecord[], wabaId: string, metadata?: { phone_number_id?: string; display_phone_number?: string }, ): Resolution => { const live = numbers.filter((n) => n.active); // Most changes carry the business number: the most specific key there is. if (metadata?.phone_number_id) { const match = live.find((n) => n.phoneNumberId === metadata.phone_number_id); return match ? { tenantId: match.tenantId, phoneNumberId: match.phoneNumberId } : { unknown: `number ${metadata.phone_number_id}` }; } // Account-level changes only name the WABA. const inWaba = live.filter((n) => n.wabaId === wabaId); if (inWaba.length === 0) return { unknown: `WABA ${wabaId}` }; if (inWaba.length === 1) return { tenantId: inWaba[0].tenantId, phoneNumberId: inWaba[0].phoneNumberId }; // Several numbers in one WABA: narrow by the formatted display number, if sent. const wanted = digits(metadata?.display_phone_number ?? ""); const byDisplay = wanted ? inWaba.filter((n) => digits(n.displayPhone) === wanted) : []; if (byDisplay.length === 1) return { tenantId: byDisplay[0].tenantId, phoneNumberId: byDisplay[0].phoneNumberId }; const tenants = new Set(inWaba.map((n) => n.tenantId)); return tenants.size === 1 ? { tenantId: inWaba[0].tenantId, phoneNumberId: null } : { unknown: `ambiguous WABA ${wabaId}` }; }; Enter fullscreen mode Exit fullscreen mode Two cases from the tests are worth calling out: A reconnected number. A business deletes its account with you, then connects the same number under a new account. Both tenant records hold the same phone_number_id, so only active records may match, or the new owner's messages land in the old owner's inbox. An unknown number isn't guessed from its WABA. If the phone number ID isn't in your table, don't fall back to "whoever owns this WABA". That's how one tenant's number ends up feeding another tenant's inbox. Park unknown events; don't create tenants from them It's tempting to auto-create an account when a webhook arrives for a number you've never seen, so messages "just work" before setup finishes. In a multi-tenant system, that creates a tenant nobody owns, holding real customers' messages, with no user who can see them and nothing tying it to a signup. Park the event instead: store it with the reason, alert on it, and replay it once the connection is complete. Unknown events are normal in a few situations, such as the seconds between a subscription and your own database write, or a tenant who has just left. Process each change on its own One POST can carry events for several tenants. If a bug in one tenant's handler throws and you answer the whole request with a 500, Meta retries the whole batch, and every other tenant gets their events again. So acknowledge after the signature check, then process each change separately, with a deduplication claim in front: import { createHash } from "node:crypto"; import { resolveTenant, type NumberRecord } from "./resolve-tenant.ts"; type Change = { field: string; value: Record & { metadata?: { phone_number_id?: string; display_phone_number?: string } } }; type Payload = { object: string; entry: { id: string; changes: Change[] }[] }; export type Deps = { numbers: () => Promise; claim: (key: string) => Promise; handle: (tenantId: string, phoneNumberId: string | null, change: Change) => Promise; park: (reason: string, wabaId: string, change: Change) => Promise; }; // Duplicate deliveries hash to the same key, so a retried change is skipped. const changeKey = (wabaId: string, change: Change) => createHash("sha256").update(JSON.stringify([wabaId, change.field, change.value])).digest("hex"); // account_update names the WABA in waba_info; entry.id can be a different ID there. const wabaIdOf = (entryId: string, change: Change): string => change.value.waba_info?.waba_id ?? entryId; // Called after the signature check and the 200. Never throws: one tenant's // failure must not fail, or replay, another tenant's events. export const processBatch = async (body: Payload, deps: Deps) => { const result = { handled: 0, duplicates: 0, parked: 0, failed: 0 }; if (body.object !== "whatsapp_business_account") return result; const numbers = await deps.numbers(); for (const entry of body.entry) { for (const change of entry.changes) { try { if (!(await deps.claim(changeKey(entry.id, change)))) { result.duplicates++; continue; } const owner = resolveTenant(numbers, wabaIdOf(entry.id, change), { phone_number_id: change.value.metadata?.phone_number_id, // Account-level changes put the display number on `value` itself. display_phone_number: change.value.metadata?.display_phone_number ?? change.value.display_phone_number, }); if ("unknown" in owner) { await deps.park(owner.unknown, entry.id, change); result.parked++; continue; } await deps.handle(owner.tenantId, owner.phoneNumberId, change); result.handled++; } catch { result.failed++; } } } return result; }; Enter fullscreen mode Exit fullscreen mode Note wabaIdOf: for account_update, the WABA ID comes from waba_info. The claim key is a hash of the change, which catches whole-batch redeliveries. In production, also dedupe on the IDs inside the change, such as message IDs, and record failures somewhere you can replay them from, because once you've answered 200, Meta won't send that change again. Scope every query by tenant After routing, the tenant ID has to follow the event into every read and write. Tenancy enforced only in application code fails the first time someone writes Contact.findOne({ where: { phone } }) without it. Make the unscoped query the hard one to write: give handlers a data handle that's already bound to a tenant. // Every read and write goes through a handle bound to one tenant. type Row = { tenantId: string; [key: string]: unknown }; export const forTenant = (table: T[], tenantId: string) => ({ find: (where: Partial = {}) => table.filter((row) => row.tenantId === tenantId && Object.entries(where).every(([k, v]) => row[k] === v)), insert: (row: Omit) => { const stored = { ...row, tenantId } as T; table.push(stored); return stored; }, }); Enter fullscreen mode Exit fullscreen mode In a real database this is a repository or query helper that adds WHERE tenant_id = ? to every query, or row-level security in Postgres. The test that matters is the negative one: a handle for one tenant must return nothing, and write nothing, for another. Onboarding: subscribe each account, and check it worked With Embedded Signup, the browser gets a code that Meta's implementation guide says "has a time-to-live of 30 seconds", so exchange it on your server straight away for a business integration system user access token. Store that token per tenant, encrypted. The Tech Provider onboarding guide then has you subscribe your app to the customer's account and register their phone number. The subscription is per account. Meta's webhook management guide says you "must individually subscribe to every Messaging account": curl -X POST "https://graph.facebook.com///subscribed_apps" \ -H "Authorization: Bearer $BUSINESS_TOKEN" # Confirm your app is listed curl "https://graph.facebook.com///subscribed_apps" \ -H "Authorization: Bearer $BUSINESS_TOKEN" Enter fullscreen mode Exit fullscreen mode Treat a failed subscription as a failed connection. If you log it and carry on, the tenant sees a connected number that can send but never receives a reply, which looks like a WhatsApp outage, not a setup error. Offboarding: listen for account_update Tenants leave from Meta's side too: they uninstall your app or unshare their account in Business Manager. Make sure account_update is among the webhook fields you receive, and react to these events, as listed in Meta's account_update reference: PARTNER_APP_UNINSTALLED: "a business customer deauthenticated or uninstalled the app". PARTNER_REMOVED: the account "has been unshared with a Solution Partner". ACCOUNT_DELETED: "the Messaging account was deleted". // What an account_update event means for the tenant's connection. export type ConnectionChange = "disconnected" | "deleted" | "connected" | "no-change"; const EFFECT: Record = { PARTNER_APP_UNINSTALLED: "disconnected", PARTNER_REMOVED: "disconnected", ACCOUNT_DELETED: "deleted", PARTNER_APP_INSTALLED: "connected", PARTNER_ADDED: "connected", }; export const connectionChange = (value: { event?: string }): ConnectionChange => EFFECT[value.event ?? ""] ?? "no-change"; Enter fullscreen mode Exit fullscreen mode On a disconnect, mark the tenant's numbers inactive, stop its scheduled sends, and tell its admins in the app. Without this, a departed tenant's campaigns keep failing with permission errors until someone notices. Account-level webhooks such as account_update always go to your app's main callback URL; Meta's override guide doesn't allow them to be redirected. Per-account and per-number overrides are useful for pointing one test tenant at staging, as in testing webhooks outside production, but your production endpoint still has to handle these events. Keep one tenant from slowing the rest Meta's limits are set per customer, not per app: throughput is per phone number ("up to 80 messages per second (mps) by default", per the throughput guide), and messaging limits are "calculated and set at the business portfolio level". Your workers are shared. One tenant's 50,000-message campaign, and the status webhooks it generates, can sit in front of another tenant's single inbound message on a shared queue. Give inbound handling its own queue, separate from bulk sending, and partition bulk sends per tenant or per number so a big campaign can only slow itself down. Checklist Loop over every entry and every change; never assume one. Verify the signature with the app secret before routing. Route by phone_number_id, then by WABA ID (from waba_info on account_update), and only to active records. Park unknown or ambiguous events; never create a tenant from a webhook. Acknowledge, then process each change independently with a dedupe claim. Bind every query to a tenant, and test that cross-tenant reads return nothing. Exchange the Embedded Signup code at once, store the business token per tenant, and fail the connection if the subscription fails. Subscribe to account_update and deactivate tenants that leave. Keep inbound processing off the queue that carries bulk sends. WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: answering WhatsApp calls in the browser and build vs buy.
One Meta App, Many Businesses: Routing WhatsApp Webhooks in a Multi-Tenant SaaS
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.