On the web, multi-tenancy is a solved boredom. The tenant is in the URL — subdomain, path segment, header. You look it up and move on.

Conversational commerce takes the URL away. A buyer opens WhatsApp, taps a number they saved months ago, types "bhej do", and your webhook fires. Somewhere in that you have to decide which merchant, and which of that merchant's stores, this person is talking to.

What the webhook actually hands you

Strip an inbound Cloud API message to the parts that identify anything and you get two fields:

{
  "metadata": {
    "display_phone_number": "919000000000",
    "phone_number_id": "1234567890"        // which of OUR numbers
  },
  "contacts": [{ "wa_id": "919876543210" }], // who messaged
  "messages": [{ "from": "919876543210", "type": "text",
                 "text": { "body": "bhej do" } }]
}

phone_number_id is the business number. wa_id is the human. There's no tenant field, because as far as Meta is concerned the number is the tenant.

So the first resolution step is the boring one, and I want to be accurate about it rather than make it sound clever: the subscription is looked up directly by phone_number_id against a configuration row in Postgres. That row is cached for five minutes, because it's read on literally every inbound message and it changes approximately never.

The interesting problem starts after that, because one subscription can own several stores.

Why not one number per store

The obvious design is a WhatsApp number per store, making phone_number_id the whole answer. It fails for reasons that aren't technical:

  • Every number needs its own business verification and display-name review. That's days-to-weeks of Meta review, and it's the merchant's paperwork, not yours.
  • Merchants don't want it. A distributor with six regional storefronts spent years getting buyers to save one number. Asking them to re-teach that is asking them to throw away their distribution.
  • It kills self-serve onboarding. The moment going live requires provisioning a phone number, "sign up and start selling" becomes "sign up and wait".

So: one number, many stores, and the fan-out happens on our side.

Two questions, not one

The mistake I made early was treating this as a single lookup. It's two independent questions, and conflating them produces a subtle and fairly serious bug.

Visibility: which of the subscription's stores can this retailer see? All of them, only ones they already have a relationship with, exactly one, or only the secondaries.

Registration: when this retailer has no relationship yet, which stores do we create one for? The primary, all of them, none, or ask them.

Four by four, held as config on the subscription. And the reason they have to be reasoned about together is this:

Browsing queries offers by store with no relationship join. Ordering needs a relationship. So pairing a wide visibility with a primary-only registration lets a retailer browse every store on the subscription and then order from one of them. That combination is rejected server-side, not just greyed out in the admin UI — a validation that only exists in the form is a validation you don't have.

The session key is the tenancy boundary

Sessions are keyed on the compound (phone, display_phone_number) — the human and the business number they're talking to.

That compound key is doing more work than it looks. One shopkeeper who buys from two different businesses, both hosted on our platform, has two completely independent sessions. Key on the phone number alone — which is the obvious thing, since that's the person — and the two conversations bleed into each other. Their cart from one business shows up while they're talking to the other.

Caching, and the entity that is never there

Two layers, with deliberately different lifetimes: the subscription config for five minutes, and the retailer's resolved store scope for thirty.

The second one needed something I don't reach for often. Salesmen and unregistered leads message the bot constantly, and none of them have a retailer record — so every one of those messages was a guaranteed cache miss followed by a guaranteed-empty database query. The fix is to cache the absence: store a marker meaning "looked, genuinely not there". Without it, the population that hits this path hardest is the one population the cache does nothing for.

Ordering bugs you only find in production

One that's worth stating because it's invisible in review. "Waiting for the retailer to pick a store" and "don't register this retailer anywhere" both end up looking like no stores resolved. If you ask the resolver for the store list before you check whether you're mid-selection, it'll confidently answer "none" and you'll skip registration for someone who was about to tell you exactly what they wanted. The check has to come first, and the only thing enforcing that is a comment and whoever reads it.

Fail towards the old behaviour

The rule I'd keep on any feature like this: every failure path returns the full store list. Not an error, not an empty set — the exact behaviour that existed before multi-store shipped.

On a subscription with no secondary stores, the whole resolver collapses to [primary], which is precisely the single relationship the old code created. So for every single-store merchant on the platform, a feature they never asked for and can't see is provably a no-op. That property is worth designing for explicitly, because it's what lets you ship a tenancy change to everyone at once instead of behind a flag you'll be babysitting for six months.

If you are about to do this

Work out what the protocol actually gives you before designing tenancy around it. For the Cloud API that's a number and a person, and neither one is a tenant.

Then separate "what can they see" from "what do we create for them", and check the combinations for the one that lets someone see more than they can act on. Make your session key compound if one user can legitimately talk to two of your tenants. Cache the misses, not just the hits. And make the degraded path the old path, so the worst case is last week's behaviour rather than a stack trace.

None of it shows up in a demo. It's the difference between onboarding a merchant in minutes and waiting weeks on Meta's business verification, and for the customers we sell to that gap is most of the decision.