Infrastructure Product

Building a multi-tenant SMS platform: architecture of 47Comms.


SMS is a solved problem โ€” until you try to build a serious platform on top of it. Then you discover that carrier behaviour is wildly inconsistent, consent management is a legal minefield, message ordering under load is not guaranteed, and most hosted providers will happily sell your message metadata to whoever is willing to pay for it. 47Comms was built because none of the existing options met our requirements for multi-tenancy, data sovereignty, and operational transparency.

This post walks through the architecture decisions that shape how 47Comms works: how messages are routed, how consent is tracked, how we handle carrier failover, and how the optional PBX bridge connects SMS to voice infrastructure.

The multi-tenancy model

47Comms is designed from the ground up as a multi-tenant platform. Each tenant โ€” a business or team using the platform โ€” operates in a fully isolated namespace. Their contacts, message threads, automations, consent records, and phone numbers are logically separated at the database level, not just behind an application-layer filter.

The data model is built around three core entities: workspaces (tenant root), channels (a phone number or sender ID assigned to a workspace), and threads (a conversation between a channel and a contact). This hierarchy makes it structurally impossible for a query scoped to one workspace to accidentally surface data from another โ€” the foreign key relationships enforce it, not just the application code.

Workspace (tenant root, isolated namespace) โ””โ”€ Channel (phone number, sender ID, or virtual number) โ””โ”€ Thread (one contact โ†” one channel) โ””โ”€ Message (inbound or outbound, with status lifecycle) โ””โ”€ Consent record (opt-in source, timestamp, audit trail)

Workspace-level isolation extends to the audit log. Every action โ€” message send, consent update, automation trigger, agent login โ€” is written to an append-only audit table partitioned by workspace. A compromised application-layer session cannot read audit records from another workspace, and a database query that crosses workspace boundaries will fail the foreign key constraint before returning results.

SMS routing and carrier abstraction

The routing layer sits between the application and the carrier APIs. Its job is to accept an outbound message request and decide which carrier to use, at what time, via which route โ€” and to handle the response, whether that's delivery, failure, or silence.

We abstract over multiple carriers using a provider interface. Each carrier implementation satisfies the same contract: send(message) returns a carrier reference ID and a provisional status, status(ref) returns the current delivery state, and handleWebhook(payload) translates the carrier's callback format into our canonical status model. Adding a new carrier means writing a new implementation of this interface, not modifying the routing logic.

Carrier selection strategy

Not all carriers are equal for all routes. A carrier that delivers reliably to Romanian numbers may have poor throughput to UK numbers, and the same carrier may have different performance characteristics for short codes vs long numbers. We maintain a routing table that maps destination country + number type โ†’ preferred carrier with a fallback chain.

The selection runs at send time:

  • Check if the workspace has a pinned carrier preference for this destination
  • Look up the routing table for destination country + number type
  • Check current carrier health scores (updated every 60 seconds from delivery receipts)
  • Select the highest-scoring carrier from the preferred chain that is currently healthy
  • If no carrier in the chain is healthy, queue the message for retry with backoff

Health scores are computed from a sliding window of the last 500 delivery receipts per carrier per route. A carrier that starts failing delivers into a lower score and is deprioritised before operators notice anything is wrong โ€” the failover happens automatically based on evidence, not on an explicit operator action.

Consent management

Consent tracking is not a nice-to-have in any jurisdiction where SMS marketing regulations apply, which is most of them. 47Comms models consent as a first-class entity with its own lifecycle, not as a boolean field on the contact record.

A consent record captures: the channel it applies to, the opt-in method (keyword reply, web form, API import, manual entry), the opt-in timestamp, the source IP or message content that triggered it, and any subsequent opt-out events. Opt-outs are never deleted โ€” they're recorded as events on the consent timeline, which means you have a full audit trail showing both the consent grant and the revocation.

Before any outbound message is sent, the routing layer checks the consent state for the destination contact on the sending channel. If consent is absent or revoked, the message is blocked and the reason is written to the audit log. This check runs in the database layer, not the application layer โ€” it's enforced even if a bug in the automation engine would otherwise bypass it.

Important: 47Comms records consent but does not provide legal advice on what constitutes valid consent in your jurisdiction. The platform gives you the infrastructure to comply โ€” the compliance strategy is yours to define and maintain.

Automations and the trigger engine

Automations in 47Comms are event-driven sequences. A trigger fires โ€” an inbound message matching a keyword, a scheduled time, an API call, a contact tag added โ€” and a sequence of actions executes: send a message, wait a duration, update a tag, call a webhook, branch on a condition.

The trigger engine is deliberately simple. Each automation is a directed acyclic graph of steps, stored as JSON in the database. Execution is handled by a queue worker that picks up triggered automations, evaluates the current step, executes the action, and enqueues the next step with any delay applied. There is no long-running process for a given automation execution โ€” each step is a discrete queue job. This makes the engine resilient to worker restarts and scales horizontally by adding workers.

The tradeoff is that very complex branching logic hits the limits of the step graph model. For workspaces that need genuinely sophisticated automation logic, the API is the better answer โ€” 47Comms exposes webhooks on every event, and the API can send messages, update tags, and query thread state. Building complex automation externally and using 47Comms as the messaging layer is the right architecture for those cases.

The PBX bridge

47Comms supports an optional PBX voice bridge that connects the SMS platform to a SIP-compatible PBX. When enabled, a workspace can route inbound calls to a virtual number through the same thread interface as SMS โ€” agents see voice calls in their inbox alongside text conversations, and can switch between them for the same contact.

The bridge is implemented as a SIP trunk that terminates at the workspace's PBX. We support Asterisk, FreePBX, 3CX, and any SIP-compatible system that accepts a standard trunk configuration. On the 47Comms side, the bridge is just another channel type โ€” the routing, consent, and audit models apply equally to voice interactions.

The voice channel does not record calls by default. Recording is an explicit workspace opt-in with a separate consent flag that must be acknowledged before the feature activates. When recording is enabled, audio is stored in the workspace's own storage bucket โ€” not on shared 47Comms infrastructure โ€” and the recording file reference is attached to the thread event.

Self-hosting

47Comms is fully self-hostable. The Helm chart packages the API server, queue worker, webhook receiver, and a PostgreSQL dependency. Carrier API credentials go into HashiCorp Vault at deployment time. The chart supports both a managed Vault instance and an external Vault cluster if you're already running one.

The main operational consideration for self-hosting is carrier registration. Most carriers require a registered sender ID or approved phone number, and that registration process varies by carrier and country. We maintain documentation on the registration requirements for the carriers we support, but the registration itself is between you and the carrier โ€” we can't do it on your behalf for a self-hosted deployment.

For teams that want the platform without the carrier registration work, the managed deployment handles carrier relationships โ€” you get a clean API and inbox without having to deal with carrier compliance directly.


โ† Back to Blog Explore 47Comms โ†’