Skip to content

messaging-service

Sends transactional and campaign messages, and records what happened. Elixir/Phoenix. Templates and schedules live in PostgreSQL; per-send delivery events are logged to MongoDB; it talks to the rest of the system over RabbitMQ.

This is the service that does the thing Beacon's customers actually pay for. Give it a recipient, a channel, and a template name and it renders the content, hands it to the right provider, and writes down what happened — delivered, failed, or bounced. Everything in the Messaging domain runs here: the send flow, the public Messaging API, and the delivery webhooks are all faces of this one service.

It is the unusual service in Beacon for spanning three datastores plus a message bus (PostgreSQL, MongoDB, RabbitMQ), and that's not accidental complexity — the job genuinely has three differently-shaped kinds of data, and we keep each where it belongs (see Two stores, on purpose and ADR 0001).

The single most important thing to understand about its role is that it reacts — it owns templates, schedules, and the delivery log, and nothing else. It does not decide who an org's users are (that's accounts-service) or what an org is entitled to send (that's billing-service). It reads those answers and acts on them. A useful one-liner: Accounts and Billing describe the world; Messaging acts in it and writes down the consequences.

Responsibilities & boundaries

  • Owns: message templates and send schedules (in PostgreSQL), and the delivery-event log (in MongoDB).
  • Owns the act of sending — rendering a template, applying per-org message settings (rate limit, quiet hours, retry policy, fallback channel), handing the message to a provider, and recording the outcome.
  • Does not own subscriptions or org/user state. It reads org status live from accounts-service (GET /orgs/{id}) and reacts to billing events; it never writes either, and it must not cache org status as long-term truth.
  • Does not decide entitlement. Whether an org may send, and at what volume, is a Billing/Accounts concern. Messaging enforces the one gate it's handed — the org's active / suspended status — and otherwise just does what it's asked.

A quick orientation diagram of who it leans on and who leans on it:

flowchart LR
  ui[Web UI / API caller] -->|POST /messages| MS[messaging-service]
  bill[billing-service] -. subscription.upgraded .-> mq[(RabbitMQ)]
  mq -. consume .-> MS
  acc[accounts-service] -->|GET /orgs/id status| MS
  pg[(PostgreSQL: templates / schedules)] --> MS
  MS -->|render + deliver| prov[(Email / SMS / Push provider)]
  MS -->|write event| mongo[(MongoDB: delivery_events)]
  MS -- message.delivered / message.failed --> mq
  MS -- message.* --> hook[Customer webhook endpoint]

Interface (contracts)

messaging-service has two faces: an inbound/outbound event surface over RabbitMQ (how it talks to the rest of Beacon), and a customer-facing HTTP + webhook surface (how your systems talk to it). Keep them straight — they share some event names but are entirely different audiences and guarantees.

Internal events (RabbitMQ, AsyncAPI)

Event-driven, described with AsyncAPI (contracts/messaging/asyncapi.yaml, AsyncAPI 3.0). The broker is declared there as rabbitmq:5672 over AMQP. Change what an event carries in that contract first — pages link to it and the asyncapi diff compatibility check consumes it.

  • Consumes: subscription.upgraded (from billing-service) → renders and sends the confirmation message. Payload carries subscriptionId, orgId, planId.
  • Produces: message.delivered (and, per canon, message.failed) after a send resolves. The MessageDelivered payload carries messageId, orgId, and a status of delivered / failed / bounced.

Discrepancy: is message.failed its own event?

Canon describes message.failed as a distinct produced event, and this page (and RabbitMQ) say so. But the current AsyncAPI contract declares only subscription.upgraded and message.delivered, and the MessageDelivered payload's status enum already includes failed and bounced. So a failed outcome may actually travel as a message.delivered event with status: failed rather than on a separate channel. Code/contract wins — either the contract should add a message.failed channel or the prose should stop promising one. Flagged for confirmation.

Customer-facing surfaces (HTTP + webhooks)

Customers never touch RabbitMQ. They reach messaging-service through:

  • Messaging API — the public REST surface (compiled from TypeSpec in contracts/public-api/). Two endpoints today: POST /messages (accept a send; returns queued) and GET /messages/{id} (read current status). Auth is a bearer key scoped per environment; see Auth.
  • Webhooks — outcomes Beacon POSTs to your endpoint (message.sent / message.delivered / message.failed / message.bounced). Signed, at-least-once, and unordered — a different reliability model from the synchronous API.

These two are the inbound and outbound sides of the same loop: you send over the API and hear back over webhooks.

Two events called message.delivered — don't conflate them

The name message.delivered exists twice: as an internal RabbitMQ event between Beacon's own services, and as a public webhook Beacon sends to you. They share a name and rough shape but are different surfaces with different guarantees and fields. The internal event is an implementation detail; the webhook is a promise to customers. When someone says "the delivered event," confirm which side of the line they mean. See Webhooks → internal events vs webhooks.

Data it owns

Three stores, each holding a different shape of data — the asymmetry is the design.

Store Holds Shape Why it fits
PostgreSQL message templates, send schedules relational, mutable, modest, edited by humans wants a schema, constraints, and joins
MongoDB delivery-event log schemaless, append-only, very high volume cheap high-volume inserts; the field set evolves freely
RabbitMQ events in flight transport, not storage decouples Messaging from Billing/Accounts
  • Delivery event — the MongoDB log, one write-once record per send attempt. This is the model worth reading in full, because its schemaless nature has a long shadow: older documents lack fields added later. provider_id only exists on records from 2024-03 onward, region from 2024-07, and attempts is absent on the oldest rows (treat absent as 1). Any code reading the log — including the emitters in this very service — must default defensively rather than assume a field is present. ADR 0002 added a MongoDB JSON-schema validator that stops new drift, but it is deliberately not retroactive, so the historical caveat still holds.

Templates and schedules don't have their own model pages yet; the PostgreSQL page describes what each half holds. Tenant isolation for them is by org_id and enforced in app code, not the database — an ad-hoc query that forgets the org_id filter will happily cross tenants.

Key flows

The headline flow is the reactive upgrade confirmation: nobody in Messaging triggers it directly — it falls out of a Billing event. This is the canonical path that ties Billing → Messaging together.

sequenceDiagram
  participant MQ as RabbitMQ
  participant Messaging as messaging-service
  participant Mongo as MongoDB
  MQ-)Messaging: subscription.upgraded
  Messaging->>Messaging: render template (PostgreSQL)
  Messaging->>Provider: send
  Messaging->>Mongo: insert delivery_event
  Messaging-)MQ: message.delivered

In words, with the gotchas surfaced where you'd hit them:

  1. Consume. A subscription.upgraded event arrives from billing-service over RabbitMQ (the upstream half of the Plan upgrade feature). Because the broker is at-least-once, this event can be redelivered — the consumer must be idempotent so one upgrade never produces two confirmations.
  2. Pick + render. The matching confirmation template is loaded from PostgreSQL and rendered. A template that fails to render is a send-time error, not a provider error — it never reaches the provider and produces no delivery event.
  3. Gate on org status. Messaging checks the org's status against accounts-service (GET /orgs/{id}). A suspended org's message is dropped here (org_suspended) before anything reaches a provider — even this system-initiated message; the gate makes no exception for internal triggers.
  4. Deliver. The rendered message goes to the channel's provider. "Accepted" means the provider took it, not that the customer received it — final delivery is asynchronous and arrives later via the provider's own callback.
  5. Record. A delivery-event is appended to MongoDB, write-once. This is the audit trail and the source for per-org delivery reporting.
  6. Announce. On the provider's confirmation, Messaging emits the internal outcome event and fires the public webhook.

Explicit sends and the send matrix

The reactive confirmation above is one of two send origins. The other — explicit sends from the API or dashboard — is the common case. Both funnel into the same machinery once a template is chosen, but the explicit path has more options: it can be immediate, scheduled (sendAt), or throttled by the per-org rate limit, and it branches by channel.

The full picture — channel × timing × org-status, with worked edge cases — lives on Send a message. The essentials to carry away here:

  • Org status is the first, cheapest gate. A suspended org is rejected before anything renders, queues, or reaches a provider.
  • The gate is re-checked at fire time, not cached. A message scheduled while an org is active is still dropped if the org is suspended before sendAt arrives. A queued message is a promise to re-evaluate, not a committed send.
  • Push is exempt from rate limiting; email and SMS are throttled (queue and drain at the configured rate). That asymmetry is mirrored in Message settings.
  • quiet_hours defers; suspension rejects. Two gates that look similar have opposite outcomes — a message inside quiet hours is held until the window ends, using the org's timezone, not the recipient's.

The send lifecycle (states)

A message walks a small set of states. Knowing them makes the webhooks and the delivery log read clearly — and makes the "accepted ≠ delivered" trap obvious.

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 --> [*]

The three terminal outcomes — delivered / failed / bounced — are exactly the status values recorded on a delivery-event and the webhook event names. queued and sent are in-flight stages you'll see while polling but never as a final answer.

A naming gap worth knowing: message.sent vs message.delivered

The current send.ex source writes a delivery event in a sent sense and the send flow diagrams show a message.sent step, while canon and the AsyncAPI contract name the produced/terminal events message.delivered / message.failed. Read sent as "handed to provider, accepted"; the terminal outcome (delivered / failed / bounced) is surfaced later. The naming inconsistency is real and tracked on the send flow — worth confirming, not inventing a resolution for here.

Why it reacts to events instead of being called

Beacon could have had billing-service call messaging-service directly over HTTP when an org upgrades. It doesn't, and the reason explains the service's whole shape:

  • Decoupling. billing-service's job ends at "the subscription is updated and the fact announced." It shouldn't fail or slow down because a confirmation email is the downstream result, or because messaging-service is briefly down.
  • Fan-out. One subscription.upgraded can grow more consumers over time (today just the confirmation; tomorrow maybe analytics) without billing-service changing.
  • Buffering. If messaging-service is restarting or backed up, events queue and are processed on recovery rather than erroring back to the caller.

The trade-off is eventual consistency — a small, usually sub-second gap between the upgrade completing and the confirmation going out. For a confirmation message that's exactly the right trade. The full rationale lives on RabbitMQ and Plan upgrade.

How this connects

Notes & nuances

Two stores, on purpose

Templates/schedules are relational (PostgreSQL); the high-volume, write-once delivery log is in MongoDB. The rationale is captured in ADR 0001, with the follow-up schema-validator decision in ADR 0002.

Consumers must be idempotent (both directions)

RabbitMQ is at-least-once: a consumer acks only after it finishes handling an event, so a crash mid-handle (after sending, before ack) means the event is redelivered on restart. The failure to design against is sending the same confirmation twice for one upgrade. The same discipline applies on the public edge — webhooks are at-least-once too, so the consumer you build on the other side must dedupe as well.

Don't cache org status as truth

Org status is read live from accounts-service at the moment a message fires — never cached as long-term truth. That live read is what keeps a long-scheduled message honest: a suspended org can't sneak out a message it scheduled while still active. The cost is an extra status read per fire; the payoff is that entitlement is always current.

SMS is effectively retired

channel: "sms" still appears in the data model, on historical delivery-event rows, and in the API's channel enum, but the SMS feature is 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 RabbitMQ pages carry matching suspected-dead flags for the leftover SMS code paths.

Suspected dead

A legacy.sms_gateway consumer is wired up but the queue it binds to has had no producers since the SMS feature was retired. Likely dead. This is a dead queue with a live-but-idle consumer attached — a cleanup candidate on both ends (also flagged on RabbitMQ, and matching the legacy sms branch noted on the send flow).

Used by: Features, Message settings, Plan upgrade, Send a message, Suspend an organization

Owns: Delivery event

Related: Add a schema validator to delivery_events, Decisions, Delivery events in MongoDB, Models, Overview, Services

Confidence: 70/100 · Last reviewed: 2026-06-14 · Status: active

Verified against: beacon-messaging@7a8b9c0 (2026-05-22)