← ALL POSTS
18 MARCH 2026

One Handset, Two Clients: Dual-SIM Tenant Routing In The Aiva SMS Gateway

Here is a problem you only get once you are actually running this stuff for real clients.

Aiva gives each client a real New Zealand mobile number. Not a shortcode, not a five-digit thing that reads like spam. An actual 021 number their customers can text and ring. The gateway is an Android handset with a SIM in it, and a NestJS API in front of it.

Fine for one client. Then you get a second client and you do not really want a second phone sitting on the shelf.

Modern handsets take two SIMs. So: one phone, two SIMs, two clients, and the software has to keep them completely separate. That sounds simple and it is not, because SMS does not carry a tidy "this is for tenant B" header. It arrives on a SIM.

Outbound: telling the phone which SIM to use

The tenant record got a new field, preferredSimSubscriptionId. When HighLevel calls our webhook asking us to send a message, we look up the tenant, read their SIM preference, and pass it down in the FCM payload to the handset.

On the Android side the app resolves which SIM to use in a strict order: whatever the backend told it, then the app's own preference, then the device default. It validates the subscription id before it uses it, then sends through SmsManager.getSmsManagerForSubscriptionId().

The trap. On a dual-SIM handset, a send that does not carry a SIM id fails. And it fails silently at the application layer. The API returns a cheerful 200, the message row says pending, and nothing ever leaves the phone.

We lost four sends that way before anyone noticed. Nobody noticed because nothing looked broken. If you build anything on top of this gateway, always pass the SIM id on a dual-SIM device. Always.

Inbound: working out who the message is for

This is the harder half. A text arrives. Which client does it belong to?

The Android app pulls the receiving SIM's subscription id straight off the broadcast intent, stuffs it into the payload alongside the message, and posts it up. The API stores it on the SMS record, then the HighLevel listener resolves the tenant by looking up which tenant has that SIM on that device.

Deterministic. No guessing from the sender's number, no "most recent conversation wins" heuristic. The SIM that received it decides who owns it.

There is a fallback for older records that came in before we stored the SIM id, and a secondary heuristic that matches on which tenant most recently messaged that number, but the SIM mapping is the one that actually runs.

The bit that was not obvious

Device lookup had to change too. It used to find the device by walking from the tenant's own record. With shared devices that is the wrong way round, because two tenants point at the same phone. It now goes through the tenant's settings.defaultDeviceId, set on both sides when an admin assigns a device to a tenant.

That assignment had its own bug worth mentioning: we were replacing the whole settings subdocument on assign, which quietly wiped anything else in there. Switched to dot-notation $set on settings.defaultDeviceId so it is an atomic update of one nested field. Mongo will happily let you blow away a subdocument if you ask it carelessly.

Also in this release

The Android app got rebranded from its TextBee origins to Aiva SMS, version 2.8.0, and the notification now reads "Aiva SMS Active" instead of something that made clients ring up asking what TextBee was.

Why bother with SIMs at all

Because a real local mobile number gets answered and a shortcode does not. People trust a number that looks like a person. That is the whole reason this gateway exists rather than us just renting a messaging API, and it is why the fiddly SIM-level plumbing is worth doing properly.

Want this working on your store? Aiva does the sales, marketing and service work - in your voice, around the clock.
Get Aiva