intermediate

Case Study: Notifications API

A platform service other product teams call to reach users over email, push, SMS, and in-app — with scheduling, preferences, and delivery tracking.

A notifications API looks like "POST a message" and is actually a small orchestration platform: fan-out across channels, user preferences that can veto everything, scheduling, and delivery that fails hours later inside a third party you don't control. The contract's central honesty is the split between accepting a notification and delivering it — the API can promise the first synchronously and only *report on* the second, which is why creation returns 202 and delivery lives in its own resource (The Async Job Pattern). The second theme: a preference veto is not an error. A user who opted out of marketing email is the system working; the contract must represent that as a delivery outcome, not a failed request.

Consumers

Product feature teams

Fire "your export finished" from any service with one call, without knowing which channels the user prefers or how email works.

Marketing / lifecycle tooling

Scheduled sends at the recipient's local time, per-campaign delivery stats, and hard guarantees that opt-outs are respected.

The product UI itself

The in-app inbox: list, unread count, mark read — reading the same notifications other channels delivered.

Requirements

  • Create a notification for a user with content per channel; the platform decides final channels from user preferences.
  • Callers can schedule delivery for a future time and cancel before it fires.
  • Users control preferences per category and channel; opt-outs are enforced by the platform, not by every calling team's goodwill.
  • Callers can query what actually happened per channel: sent, delivered, bounced, suppressed — and why.
  • Delivery outcomes are pushed to callers who want them (webhooks), pollable for everyone.
  • A caller retrying a timed-out create must not notify the user twice.

Resources

Notification

The caller's intent: recipient, category, content, schedule. Its lifecycle (`scheduled` → `processing` → `done` | `canceled`) is about orchestration, deliberately *not* about per-channel outcomes — those belong below.

Delivery

One channel's attempt for one notification, with its own state machine (`queued` → `sent` → `delivered` | `bounced` | `suppressed`). A notification fans out to N deliveries; keeping them separate is what lets "email bounced, push succeeded" be representable at all.

Preference

A user's per-category, per-channel matrix. A resource with a real read/write API because the product UI edits it — and because enforcement must live in the platform, preferences must live where the platform can read them.

Template

Named, versioned content with variables, managed at deploy time. Referencing a template beats inlining content for anything recurring: consistent rendering, per-template stats, and copy fixes without redeploying the calling service.

Operations

OperationPurposeDesign notes
POST /notificationsCreate a notification (immediate or scheduled).Returns `202` with status: "scheduled" — fan-out, preference checks, and provider calls happen async, and the caller's request must not wait on an email provider's p99. Idempotency-Key required: the retried "export finished" that pings a user twice is this API's signature failure.
GET /notifications/{id}Orchestration status plus a per-channel delivery summary.Embeds the delivery list (bounded: max 5 channels) so the common "what happened?" is one call.
GET /notifications/{id}/deliveriesFull per-channel detail: provider ids, timestamps, failure/suppression reasons.A suppressed delivery carries reason: "user_opted_out" — the veto is *data*, visible and auditable, never a swallowed send or an HTTP error.
DELETE /notifications/{id}Cancel a scheduled notification.Only from scheduled; once processing, cancellation returns 409 INVALID_STATE because SMS already handed to a carrier cannot be recalled — the contract refuses to promise what physics won't deliver.
GET /users/{id}/preferencesRead a user's category × channel matrix.Returns explicit values for *every* category including defaulted ones, so clients render the settings screen without re-implementing the default rules.
PUT /users/{id}/preferences/{category}Replace one category's channel settings.PUT per category, not one giant document: two settings screens saving concurrently can't silently overwrite each other's unrelated categories (The Lost Update, Step by Step contained by narrowing the write).
GET /notificationsList notifications by recipient, category, status, time range.Cursor-paginated; serves both the in-app inbox (recipient=me&channel=in_app) and team dashboards — one list contract, two audiences.
POST /webhook-endpointsRegister a caller endpoint for `delivery.updated` events.Events carry event_id and are at-least-once; consumers dedupe (Consumer-Side Idempotency). Polling deliveries remains the documented source of truth.

Error contract

CodeStatusWhenRetryable
VALIDATION_FAILED400Missing recipient, unknown category, or template variables that don't match the template's schema — with field-level details.no
TEMPLATE_NOT_FOUND404Referenced template id or version doesn't exist in this environment — usually a staging/production config drift.no
SCHEDULE_IN_PAST422`send_at` is in the past beyond clock-skew tolerance (60s). Within tolerance the platform sends immediately instead of failing — skew shouldn't punish callers.no
INVALID_STATE409Canceling a notification already `processing` or `done`. Body carries current state.no
RATE_LIMITED429Caller exceeded their creation budget — protects downstream providers from one team's runaway loop. `Retry-After` set.after delay
PROVIDER_UNAVAILABLE503The platform itself can't accept work (queue outage). Never returned for downstream email/SMS provider failures — those are async delivery outcomes, not request errors.after delay

Decision log

Decision → reason → alternative → trade-off. The alternative is part of the record.

Accepting (`202`) is decoupled from delivering; Delivery is its own resource.
Reason · Delivery outcomes arrive over hours (bounces) from systems the platform doesn't control. A synchronous contract would either lie or hold connections forever; 202 plus a queryable Delivery record tells the truth about what the platform can actually promise (Long-Running Operations: 202 and the Job Resource).
Alternative · Synchronous send for "fast" channels, async for the rest.
Trade-off · Even trivial in-app notifications return 202, and callers wanting confirmation must poll or subscribe — one uniform model was chosen over per-channel special cases.
Preference enforcement lives in the platform; suppression is a delivery outcome, not an error.
Reason · If enforcement were the callers' job, one forgetful team violates user consent (a legal problem, not a bug). Rejecting the *create* would also be wrong: it forces every caller to handle opt-outs, when the whole point is that they shouldn't care.
Alternative · Reject creation with 422 USER_OPTED_OUT when all channels are vetoed.
Trade-off · Callers who genuinely need to know "did anything go out?" must check deliveries — the follow-up read is the price of keeping the create path consent-agnostic.
Category-based preferences with platform-defined categories, and `transactional` cannot be fully disabled.
Reason · Per-notification-type preferences explode into an unusable settings screen; categories keep the matrix human-sized. Security and legal messages must always have a path to the user.
Alternative · Free-form caller-defined preference keys.
Trade-off · Adding a category is a governance event, not just a string — deliberate friction that keeps the preference screen meaningful.
Idempotency keys required on creation.
Reason · The caller is always a machine in a retry loop, and the failure mode (user pinged twice, or 40,000 users pinged twice from a batch job) is reputationally expensive and unfixable after the fact (Idempotency Keys: The Mechanism).
Alternative · Best-effort dedup on (recipient, template, 5min).
Trade-off · A key store to run; heuristic dedup was rejected because two legitimate "new login" alerts within 5 minutes are exactly the ones you must not merge.
Webhooks for delivery events, with polling as the documented floor.
Reason · Campaign tooling wants push-based stats, but webhook consumers come and go; the pull path must be complete on its own or missed webhooks become data loss (Webhook Delivery: States, Retries, Redrive).
Alternative · Webhooks as the only outcome channel.
Trade-off · Delivery records need queryable retention (90 days) — storage the platform pays so callers can always reconcile.

How it evolves

  • New channel (WhatsApp) is a new channel value in deliveries plus template variants. Clients were required from V1 to ignore unknown channel values in delivery lists (Enum Evolution: The New Value That Broke Old Clients), so old dashboards simply don't render the new row until updated.
  • Digests / batching ("bundle my mentions hourly") arrive as a preference-level frequency setting; the Notification contract is untouched — callers keep sending singles, the platform coalesces, and deliveries reference the digest that carried them.
  • Recipient-local-time scheduling extends send_at with send_at_local: {time, timezone_source} alongside the absolute form — additive; absolute scheduling keeps working unchanged.
  • Per-caller analytics (GET /stats?template=…&period=…) is a read-only additive surface computed from the delivery records that already exist — evolution paid for by having modeled Delivery as data from day one.

Lessons behind this design