Messaging¶
Sending transactional and campaign messages to an organization's customers, across channels, and recording what happened. The hub for the send flow, the public API, and the delivery webhooks.
Messaging is where Beacon does the thing customers actually pay for: it takes a message an organization wants to send — an order receipt, a password reset, a weekly campaign — turns a template into a finished message, hands it to a delivery provider, and keeps an honest record of what happened to it. Everything in this domain hangs off two verbs: send and track.
The domain has a deliberately tight scope. It begins at "send" — the moment something (a dashboard click, an API call, or an internal event) asks for a message to go out — and ends at "delivered" or "failed". It does not decide who the customers are, and it does not decide what an org is allowed to do. Those are the neighbouring domains, and Messaging treats their answers as inputs:
- Accounts owns organizations and users — the tenants Messaging
sends on behalf of, and whose
active/suspendedstatus gates every send. - Billing owns plans and subscriptions — the entitlement that decides what an org may send and at what volume. When a subscription changes, Billing emits an event that Messaging reacts to.
So a useful mental model: Accounts and Billing describe the world; Messaging acts in it and writes down the consequences.
Capabilities¶
What this domain lets users do — the breadth, in plain terms. Each links to where it's documented in depth.
- Send a message — via the API or the dashboard. The path isn't a single straight line: it branches by channel, by when you want it sent, and by the org's status. See Send a message for the full matrix and worked scenarios.
- Choose a channel — email, SMS, or push. Email is the live, first-class channel; push rides the same send path but is exempt from rate limiting; SMS exists in the model and on historical records but is effectively retired (see Notes & nuances).
- Schedule & throttle — send now, hold until a future
sendAt, or queue and drain at a per-minute rate limit. The knobs live in message settings, and the scheduler re-checks org status at fire time, not just at enqueue. - Track delivery — every attempt writes a
delivery event, and outcomes are pushed to your
endpoint as delivery webhooks
(
message.sent,message.delivered,message.failed,message.bounced).
How it fits together¶
One service owns this whole domain: messaging-service (Elixir/Phoenix). It is unusual in the system for spanning three datastores plus a message bus, because the job genuinely has three different shapes of data:
- PostgreSQL — message templates and schedules. Relational, edited, queried by humans — a natural fit for a SQL store.
- MongoDB — the delivery event log. High volume, write-once, and schemaless on purpose so the record shape can evolve without migrations.
- RabbitMQ — how Messaging hears about the rest of the world (it consumes
subscription.upgraded) and announces outcomes back to it (message.delivered,message.failed).
The service is fundamentally event-reacting: it owns no subscription or org state, reads what it needs from events and from the Accounts/Billing APIs, and keeps only what's truly its own — templates, schedules, and the delivery log.
flowchart LR
svc[messaging-service] --> providers[(Email / SMS providers)]
svc --> mongo[(delivery_events)]
svc -- message.delivered --> wh[Webhooks]
A wider view of how a send actually travels — from the trigger, through rendering, out to a provider, and back as a recorded outcome:
flowchart LR
ui[Web UI / API] -->|send| svc[messaging-service]
bill[billing-service] -. subscription.upgraded .-> mq[(RabbitMQ)]
mq -. consume .-> svc
acc[accounts-service] -->|GET /orgs/id status| svc
pg[(PostgreSQL: templates / schedules)] --> svc
svc -->|render + deliver| prov[(Email / SMS / Push provider)]
svc -->|write event| mongo[(MongoDB: delivery_events)]
svc -- message.delivered / message.failed --> mq
svc -- message.* --> hook[Customer webhook endpoint]
The pieces of this domain, and where each is documented in depth:
| Piece | What it is | Page |
|---|---|---|
| messaging-service | The single service that owns the domain | messaging-service |
| Send a message | The send flow, as a channel × timing × status matrix | Send a message |
| Delivery event | The write-once outcome record (MongoDB) | Delivery event |
| Public API | How customers trigger sends | API |
| Webhooks | How customers hear outcomes | Webhooks |
| Message settings | Scheduling, rate limits, retry policy | Message settings |
The send lifecycle¶
A message moves through a small set of states, and knowing them makes the webhooks
and the delivery log read clearly. Org status is the first gate — a suspended org
never gets past the door — and after that, scheduling and provider behaviour decide
the rest:
stateDiagram-v2
[*] --> rejected: org suspended (org_suspended)
[*] --> queued: sendAt in future / over rate limit
[*] --> sent: delivered to provider now
queued --> rejected: org suspended before it fires
queued --> sent: time arrives, limit clears
sent --> delivered: provider confirms
sent --> bounced: hard bounce (email)
sent --> failed: retries exhausted
delivered --> [*]
bounced --> [*]
failed --> [*]
rejected --> [*]
Two things worth internalising:
sentis notdelivered. Handing a message to a provider is the start of the story, not the end. Confirmation (message.delivered) can arrive much later, and sometimes never — which is why the webhook layer is at-least-once and out of order.- Status is re-checked at fire time. A message scheduled while an org is active
is still dropped if the org is suspended before
sendAtarrives. The decision is made when the send actually happens, not when it was requested.
Where the data lives¶
Each kind of data in this domain has a home that matches its shape. The summary:
| Data | Store | Shape & why |
|---|---|---|
| Templates, schedules | PostgreSQL | Relational, edited, queried — wants a schema |
| Delivery events | MongoDB | High-volume, write-once, schemaless so it can evolve |
| Internal events | RabbitMQ | Decouples Messaging from Billing/Accounts |
The choice to keep delivery events in MongoDB rather than a relational table was a
deliberate one — the rationale (volume, append-only access, tolerance for an
evolving record shape) is captured in
ADR 0001. The practical
consequence shows up constantly: because the collection is schemaless, fields
were added over time and older documents simply lack them. provider_id only
exists on documents from 2024-03 onward, region from 2024-07, and attempts is
absent on the oldest rows (treat it as 1). Any code reading the log must default
defensively rather than assume a field is present; the
delivery-event model page lists every cutoff.
Two kinds of events (don't conflate them)¶
The name message.delivered appears in two different places, and they are not the
same surface:
- Internal events travel over RabbitMQ between Beacon's own services. This is how billing-service tells messaging-service that a subscription was upgraded, and how messaging-service announces an outcome back into the system.
- Webhooks are the customer-facing notifications Beacon
POSTs to your endpoint. They share names and rough shapes with the internal events but are a separate, signed, retried public contract — see Webhooks.
When someone says "the delivered event," it's worth confirming which side of that line they mean. The internal one is an implementation detail of the system; the webhook is a promise to customers.
A worked example: the upgrade confirmation¶
The clearest way to see the domain end-to-end is the confirmation message that goes out when an org upgrades its plan. Nobody in Messaging triggers this directly — it falls out of a Billing event:
- A user upgrades in the Web UI; billing-service
applies the change (with proration) and emits
subscription.upgradedto RabbitMQ. - messaging-service consumes the event, looks up the matching template in PostgreSQL, and renders it.
- It checks the org's status against
accounts-service — a
suspendedorg's message is dropped before it ever reaches a provider. - For an
activeorg, it hands the rendered message to the email provider and writes a delivery event to MongoDB. - On provider confirmation it emits
message.deliveredinternally and fires the publicmessage.deliveredwebhook to any subscribed endpoint.
This is the canonical path that ties Billing → Messaging together. Notice that Messaging never reads or writes subscription state — it learns that an upgrade happened from one event, and gets what it needs about the org from the Accounts API. It contributes the one thing it owns: a recorded, delivered message.
Edge cases & gotchas¶
A few situations that trip people up — surfaced here so you meet them before they bite:
- Suspended mid-flight. A message can be accepted while an org is active, sit
queuedfor two hours, and then be dropped because the org was suspended in the meantime. Status is re-evaluated at fire time. See the scheduled-SMS scenario. - Provider outage on a throttled send. Throttled email queues, the provider
returns a
503, retries follow the message-settings retry policy, and exhausted retries land afaileddelivery event plus amessage.failedwebhook. A failure is still a recorded outcome, not a silent drop. - Late or missing delivery confirmation.
sentdoes not implydelivered. Build downstream logic against the current state of a message, not the arrival order of webhooks — they are at-least-once and unordered. - Reading old delivery events. Don't assume
provider_id,region, orattemptsexist on a given document; default in code. This is the single biggest source of surprises when querying the log.
Notes & nuances¶
Boundaries
Who the customers are (orgs, users) is the Accounts domain; what the org pays for is Billing. This domain starts at "send" and ends at "delivered/failed". Messaging owns templates, schedules, and the delivery log — and nothing about subscriptions or org identity. It reads org status from accounts-service and reacts to Billing events, but it is never the writer of either.
SMS is effectively retired
channel: "sms" still appears in the model and on historical delivery events,
but the SMS feature has been retired — no new SMS rows are written, and the live
channels are email (first-class) and push. Treat SMS as read-only history. The
send flow and
messaging-service both carry suspected-dead
flags for the leftover SMS-gateway code paths.
Why one service, three stores
It looks like a lot of moving parts for one domain, but each store earns its place: PostgreSQL for relational templates/schedules, MongoDB for the high-volume append-only delivery log, and RabbitMQ to keep Messaging decoupled from Billing and Accounts. The trade-off is documented in ADR 0001.
Related pages¶
In this domain: API authentication, Delivery event, Email provider, Message settings, Messaging API, messaging-service, Python SDK, Send a message, SMS provider, Webhooks
Confidence: 82/100 · Last reviewed: 2026-06-15 · Status: active