Multi-Tenant Phone Routing and Stateless Button Flows in a WhatsApp Webhook
Senquel handles WhatsApp messages for multiple interior design firms on a single webhook. The same phone number can appear as a designer in one firm, a client in another, or both. This post covers how we resolved that safely — and the stateless pattern we use so users can navigate multi-step flows without any session storage.
A multi-tenant system serves multiple independent customers (tenants) from the same codebase and database. Each customer's data is completely isolated — firm A cannot see firm B's projects, clients, or messages, even though they share the same infrastructure.
The challenge: any lookup that forgets to scope by firm can accidentally return or write another firm's data. This is not a theoretical risk — it is a silent data corruption bug that is hard to detect until a second firm is onboarded.
1. Firm-first phone resolution
The original webhook called getProjectByPhone(senderPhone) — a global query that returned the first matching project across all firms. With one firm on the platform this was fine. With two firms it became a silent data corruption risk: a designer at Firm A could inadvertently approve milestones on Firm B's project if their phone number happened to appear in Firm B's client records.
The fix was a typed resolver with ordered, explicit passes. The rule: identify the firm before the resource. Once we know which firm the sender belongs to, every subsequent query is scoped to that firm.
firm_members.whatsapp_phoneprojects.designer_phone (legacy field)whatsapp_phone column.projects.client_phone across all firms4-pass phone resolution flow
The return type is a TypeScript discriminated union — the switch statement in the handler can't compile without covering every case:
type ResolvedContext =
| { kind: 'designer'; firmId: string; project: Project }
| { kind: 'designer_no_project'; firmId: string }
| { kind: 'client'; firmId: string; project: Project }
| { kind: 'ambiguous'; matches: Array<{ id: string; firmId: string; name: string }> }
| { kind: 'unknown' };
// The switch is exhaustive — TypeScript errors if a case is missing
switch (ctx.kind) {
case 'designer': case 'client': /* ... */ break;
case 'designer_no_project': /* ... */ break;
case 'ambiguous': /* ... */ break;
case 'unknown': default: /* ... */ break;
}
A discriminated union is a type that can be one of several shapes, distinguished by a literal field (the discriminant). In the code above, kind is the discriminant.
When you switch on ctx.kind, TypeScript narrows the type inside each case — so in case 'designer', TypeScript knows ctx has firmId and project fields. If you add a new kind to the union but forget to handle it in the switch, TypeScript reports a compile-time error. This is the main benefit: exhaustiveness checking catches missing cases before they become bugs.
2. The missing pass and the lesson it teaches
Pass 1.5 was missing from the first version of resolveContext(). We added the new firm_members.whatsapp_phone column in migration 014 and rewrote the resolver around it — but existing designers had set their phone number in projects.designer_phone, the older field. The new resolver checked the new column (nothing there yet), skipped to Pass 2, found the phone in a client record, and routed every inbound designer message as if it came from a client. The designer's /status Active commands were being rejected with "Only the designer can update project status" — because the resolver thought they were a client.
The lesson: when you introduce a new canonical field, the resolver needs both the old and the new lookup until every record has been migrated. The fallback isn't technical debt — it's a required migration path. The pass should document clearly what it's waiting for and when it can be removed:
// Pass 1.5: legacy fallback — check projects.designer_phone.
// Remove once all firm_members.whatsapp_phone values are backfilled.
const { data: designerRow } = await svc
.from('projects')
.select('id, firm_id')
.eq('designer_phone', senderPhone)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
3. Stateless button flows via prefix-encoded IDs
WhatsApp interactive buttons send the button's id field back in the webhook payload when tapped. Early on, button IDs were display labels ("View Summary", "Help") mapped to commands in a lookup table. This broke down once we needed per-project context: a client linked to multiple projects would pick one from a list, get the project summary — and then their next message would be ambiguous again, because nothing remembered which project they'd chosen.
The correct model: embed the resource ID inside the button ID itself. Every button tap is fully self-contained — no session, no cookie, no database row tracking "user X is in context Y".
A stateless system does not remember anything between requests. Each incoming message is handled in complete isolation — the handler gets only what arrived in that specific request, and forgets everything when it returns a response.
The alternative is session state: a server-side store that remembers "user X is in the middle of approving a milestone." Sessions add infrastructure (Redis, a database table), create expiry and cleanup problems, and break if the user switches devices or the server restarts.
The prefix-encoded button ID pattern achieves the same multi-step flow as a session, but statelessly: each button tap carries its own full context, so the handler never needs to remember anything.
// Sending buttons — resource ID travels in the button id field
await metaSendButtons(senderPhone, 'Which project?',
matches.map(m => ({
id: 'sel:' + m.id, // prefix:uuid — tells the handler what to do and on what
title: m.name.slice(0, 20), // display label, separate from routing data
}))
);
// Receiving — prefix tells you the action; the rest is the resource ID
if (cleanBody.startsWith('sel:')) { const projectId = cleanBody.slice(4); /* … */ }
if (cleanBody.startsWith('do_approve:')) { const projectId = cleanBody.slice(11); /* … */ }
if (cleanBody.startsWith('mil:')) { const milestoneId = cleanBody.slice(4); /* … */ }
The prefix scheme (sel:, do_approve:, mil:) is a command namespace. Each prefix maps to one handler block. Adding a new action type means adding a new prefix — no changes to existing handlers, no lookup table to update.
Meta's constraints on button IDs (max 256 characters) and titles (max 20 characters) are worth noting as a design constraint that works in your favour. The 20-character title limit forces you to separate the display label from the routing data — which is the right separation anyway. A UUID is 36 characters; prefix + UUID fits in 256 characters with room to spare. The constraints produced a cleaner design than we'd have written without them.
The full button flow for a client selecting a project and approving a milestone:
sel:<projectA-id> / sel:<projectB-id>sel:<projectA-id>do_approve:<projectA-id> / sel:<projectA-id>do_approve:<projectA-id>mil:<milestone-id>mil:<milestone-id>At no point does the webhook store anything between messages. The entire context for each step is carried in the button ID that arrived in the previous step's response.