← Back to blog

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.

📘 What does multi-tenant mean?

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.

Pass 1  — firm_members.whatsapp_phone
Designer identity is registered per firm. Match → firm context confirmed; all queries scoped to that firm_id.
Pass 1.5projects.designer_phone (legacy field)
Needed because existing designers had not yet populated the new whatsapp_phone column.
⚠ This pass was absent in the first rewrite. All designer messages were misclassified as client messages.
Pass 2  — projects.client_phone across all firms
One match → proceed as client. Multiple matches → ambiguous; send project-picker buttons.
Pass 3  — unknown sender
Reply with a registration hint.

4-pass phone resolution flow

senderPhone from webhook Pass 1: firm_members.whatsapp_phone Is this number a registered designer? match kind: 'designer' no match Pass 1.5: projects.designer_phone (legacy) Fallback for pre-migration records match kind: 'designer' no match Pass 2: projects.client_phone (all firms) Is this number a client on any project? match kind: 'client' no match Pass 3: unknown sender Not a designer or client on any project kind: 'unknown' designer match client match unknown sender

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;
}
📘 What is a TypeScript discriminated union?

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".

📘 What does stateless mean?

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:

Client sends any message → ambiguous (2 projects)
↓ webhook sends buttons: sel:<projectA-id> / sel:<projectB-id>
Client taps "Project A" → webhook receives sel:<projectA-id>
↓ sends project summary + buttons: do_approve:<projectA-id> / sel:<projectA-id>
Client taps "Approve Milestone" → webhook receives do_approve:<projectA-id>
↓ loads project, filters milestones by status, sends buttons: mil:<milestone-id>
Client taps milestone → webhook receives mil:<milestone-id>
↓ advances milestone, sends confirmation text

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.

Comments

Sign in to leave a comment.