Delivery event¶
A write-once record of one message send attempt and its outcome. High volume, append-only, stored in MongoDB by messaging-service. See ADR 0001 for why it's in Mongo, and ADR 0002 for the validator that now guards new writes.
This is the busiest data model in Beacon. Every transactional and campaign message the product sends ends its life as exactly one delivery event: the small, permanent note that says "we tried to send message X to org Y, and here's what happened." At Beacon's volume that's millions of new records a day, and none of them is ever changed once written. If you're building a delivery report, a bounce dashboard, or anything that asks "did our customer's messages arrive?", this is the model you read.
The most important thing to internalize before you write a line of code against it: this collection has no single fixed shape. It's schemaless, it has accumulated fields over years, and older records genuinely lack fields that newer ones carry. That's by design, not corruption — and it's the one fact that trips up everyone who assumes a field is always there. We'll come back to it repeatedly, because coding defensively is the whole job here.
Business view¶
Plain language — safe for non-engineers.
| Attribute | What it means | Rules / behavior |
|---|---|---|
| Outcome | Whether the message arrived | Delivered, failed, or bounced |
| Channel | How it was sent | Email today; SMS only on old records (retired) |
| When | Time of the attempt | One record per attempt; never updated |
| Org | Which customer it belongs to | Used for per-customer delivery reporting |
Each row is a fact about the past. Unlike a Subscription, which
moves through a lifecycle (trialing → active → past_due → …) and is edited in
place, a delivery event has no lifecycle of its own — it's born already final. There
is no "the message became delivered later." If the same message is retried and a
second attempt succeeds, that's a second event, not an edit to the first. Read the
log as a stream of immutable observations, not as the current state of a message.
A delivery event's life is short and one-directional: the message gets sent, the provider hands back a verdict, and we write the event down.
flowchart LR
A["message sent via provider"] --> B{"provider verdict"}
B -->|accepted, reached inbox| C["status: delivered"]
B -->|transient/permanent send error| D["status: failed"]
B -->|recipient rejected it| E["status: bounced"]
C --> F[("one row written to\nmessaging.delivery_events")]
D --> F
E --> F
What each outcome means in practice:
delivered— the provider accepted the message and reports it reached the recipient. This is the happy path and the bulk of the volume.failed— the send attempt itself didn't succeed (provider error, send-time rejection, timeout). Distinct from a bounce: failure is our side / the send, a bounce is the recipient's mailbox.bounced— the recipient's mail system rejected the message after we handed it off (bad address, full mailbox, blocked). Useful for list-hygiene reporting.
These three outcomes also drive the events messaging-service emits downstream:
a delivered event corresponds to message.delivered, and failed/bounced map to
message.failed on RabbitMQ. So the delivery-event row and the published event are
two records of the same moment — the row is the durable log, the event is the
notification. (See messaging-service.)
How it maps to storage¶
This is a schemaless MongoDB collection, so "the model" is really the union of shapes that have existed over time. Newer fields simply don't exist on older documents — that's expected, not corruption. The reference below describes the current shape and flags where older documents differ. Because there's no schema enforcement on existing data, every consumer must code defensively rather than assume a field exists.
Two storage facts shape everything you do with this model:
- Field presence depends on a record's age. A document written in 2023 is a smaller object than one written today. Fields were added by simply having the writer start including them — no migration, no backfill — which is exactly the cheap-to-evolve property ADR 0001 wanted, and exactly the drift you have to handle on read.
- New writes are now validated; old data is not. As of
ADR 0002, a MongoDB
JSON-schema validator (
validationLevel: "moderate",validationAction: "error") guards the collection. It requires the core four fields with correct types and a validstatus/channelon new writes, and rejects malformed ones at insert time. It is deliberately not retroactive — historical documents are grandfathered and never re-checked — so the drift caveat still holds for old data. Don't read "validator added" as "every field is guaranteed everywhere."
So there are effectively two populations in one collection: pre-validator documents (trust nothing; check presence) and post-validator documents (you can lean on the core four, but the age-dependent fields are still optional). When you read across a date range that straddles the validator, you'll see both — design for the looser one.
Everything below is engineer-facing reference.
Document shape¶
For a schemaless store, "Present on" replaces nullability — it's the field that matters most here.
| Field | Type | Present on | Notes |
|---|---|---|---|
_id |
ObjectId | all | Mongo's PK; its timestamp is a rough write-time proxy |
message_id |
string | all | soft ref to the message; in required |
org_id |
string | all | soft ref to Organization; in required |
status |
string | all | delivered / failed / bounced; enum-validated on new writes |
channel |
string | all | email; sms only on legacy rows; enum-validated on new writes |
provider_id |
string | ≥ 2024-03 | missing on older documents; optional even now |
region |
string | ≥ 2024-07 | added later; older docs have none; optional even now |
attempts |
int | most | absent on oldest rows — treat absent as 1 |
debug_payload |
object | a few 2023 rows | see suspected dead |
The core four — message_id, org_id, status, channel — are the only fields
the validator marks required. They appear on every document, old and new, which is
what makes requiring them safe under moderate validation. Everything else is
age-dependent and optional by design, so a brand-new, perfectly valid document can
still omit provider_id, region, or attempts.
Field presence drifts by record age
The single most important fact about this collection: older documents lack
fields added later. Known cutoffs — provider_id (before 2024-03), region
(before 2024-07), attempts (oldest rows). Filter on existence or default in
code; never assume universal presence. The
ADR 0002 validator stops
new drift but does not fix this for historical data.
Reading defensively¶
Because the shape varies by age, every read either checks for a field or supplies a default. A few patterns you'll reach for constantly:
// Treat a missing attempts as 1 (the documented default).
const attempts = doc.attempts ?? 1;
// "region is unknown" is a real, valid state for older rows — not an error.
const region = doc.region ?? "unknown";
// Aggregating only rows that actually carry a field: filter on existence first.
db.delivery_events.aggregate([
{ $match: { region: { $exists: true } } },
{ $group: { _id: "$region", n: { $sum: 1 } } }
]);
The trap is the aggregate report that spans a date range crossing a cutoff: half the
window has region, half doesn't, and a naïve $group silently buckets the older
half under null. Decide explicitly whether absent means "unknown" or "exclude."
Query patterns it's shaped for¶
ADR 0001 chose this store for two read patterns, and they're the ones to design around:
- Recent-window, per-org lookups — "what happened to this org's sends in the last
few days?" Filter by
org_idand a recent time bound (via_idor a write-time field). - Aggregate reporting — delivery / bounce / failure rates over a period, often
grouped by
status,channel, orregion.
Neither pattern joins across the log, and neither touches old rows often — which is why a schemaless append-only store fits and why cross-store joins (below) stay in application code.
Defaults & derivations¶
| Field | DB default | App-level default / derivation |
|---|---|---|
| any field | none (MongoDB applies no defaults) | the writer sets every field explicitly at insert |
attempts (when reading old rows) |
— | assume 1 if the field is absent |
region / provider_id (when absent) |
— | treat as "unknown"; never assume a value |
MongoDB never fills a field in for you, and the validator doesn't add defaults either
— it only accepts or rejects. So every value you see was written explicitly by the
producer, and every value you don't see is genuinely absent. "Default attempts to
1" is a read-side convention, not something stored; the row really has no
attempts field.
Constraints & validation¶
| Rule | Enforced in | Detail |
|---|---|---|
| Core four present + correct types | DB (new writes) | required: [message_id, org_id, status, channel] in the validator; not retroactive |
status ∈ |
DB (new writes) + app | enum in the validator; also set by the writer. Old rows never re-checked |
channel ∈ |
DB (new writes) | enum permits sms for legacy safety; no new SMS is written |
| Age-dependent fields present | none | provider_id / region / attempts stay optional even post-validator |
| Write-once (no updates) | app | convention only — nothing prevents an update |
This section changed with ADR 0002: the collection went from zero DB enforcement to a forward-only validator. The nuance worth holding onto:
- A bad new write fails loudly and locally. A typo'd status like
"deliverd"is refused with aDocumentValidationFailureat insert time, next to the code that caused it — not days later in a skewed report. Reassuringly, a rejected write corrupts nothing; it's simply not stored. - Old data is untouched.
moderatevalidation never re-checks documents that already exist, so a 2023 row missingprovider_id(or even an oldsmsrow) reads exactly as before. - Write-once is still only a convention. Nothing in the store prevents an
update; we rely on the writer never issuing one. Treat the log as immutable because the code does, not because Mongo enforces it.
Relationships¶
All logical — MongoDB, so there are no foreign keys.
| Related model | Via | Cardinality | Enforcement |
|---|---|---|---|
| Message | message_id |
many events → one message | cross-store (none) |
| Organization | org_id |
many events → one org | cross-store (none) |
erDiagram
ORGANIZATION ||--o{ DELIVERY_EVENT : "receives sends for"
MESSAGE ||--o{ DELIVERY_EVENT : "attempt recorded by"
DELIVERY_EVENT {
string message_id "soft ref"
string org_id "soft ref → Organization"
string status "delivered/failed/bounced"
string channel "email (sms legacy)"
string provider_id "≥ 2024-03"
string region "≥ 2024-07"
int attempts "absent ⇒ 1"
}
Both references are soft: org_id points at an Organization
that lives in MySQL behind accounts-service, and message_id points at a message
owned by messaging-service itself (its templates and schedules live in PostgreSQL; the
event log lives here in MongoDB). Nothing keeps them honest. An org can be deleted
while its delivery events live on, so a join from an event to its org (or to that
org's Subscription) happens in application code and must tolerate a
missing target. There is no cascade, no FK, no referential check — by design, per
ADR 0001.
Notes & nuances¶
Deprecated / historical
channel: "sms" rows exist from the retired SMS feature. No new SMS rows are
written. They're kept for historical reporting, and the validator still permits
sms so the legacy value never trips a check — see the
validator rationale. The
matching dead consumer is flagged on
messaging-service.
Future / cleanup
A one-time best-effort backfill of region (derived from provider_id) has been
discussed but not scheduled; it would narrow historical drift, not eliminate the
pattern. Tightening the validator from moderate to strict only makes sense
after such a backfill — and since events are write-once, it'd change little in
practice. Both are noted in
ADR 0002 → Future / cleanup.
Suspected dead
debug_payload appears on a handful of 2023 rows only — looks like a temporary
debugging field left in by accident. Not written by current code, and not part of
the validator schema, so any new write carrying it would still be accepted (it's
not in required, and extra fields aren't forbidden) — but nothing produces it.
Sources & confidence¶
Derived from messaging-service's writer code plus sampling the collection across date
ranges, cross-checked against
ADR 0001 and
ADR 0002 for the storage and
validation rationale. Confidence is moderate (62) precisely because historical shapes
are hard to fully enumerate from code alone — the exact cutoff for the oldest fields
(attempts) and the full set of one-off legacy fields like debug_payload come from
sampling, not an authoritative schema. The validator's existence is well-attested
(ADR 0002, accepted); its exact deployed definition should be re-verified against
messaging-service's storage setup on the next pass.
Related pages¶
Used by: Message settings, Send a message
Owned by: messaging-service
Related: Add a schema validator to delivery_events, Decisions, Delivery events in MongoDB, Models, Overview, Services
Confidence: 62/100 · Last reviewed: 2026-06-14 · Status: active
Verified against: beacon-messaging@7a8b9c0 (2026-05-22)