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
Fire "your export finished" from any service with one call, without knowing which channels the user prefers or how email works.
Scheduled sends at the recipient's local time, per-campaign delivery stats, and hard guarantees that opt-outs are respected.
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
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.
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.
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.
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
| Operation | Purpose | Design notes |
|---|---|---|
| POST /notifications | Create 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}/deliveries | Full 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}/preferences | Read 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 /notifications | List 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-endpoints | Register 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
| Code | Status | When | Retryable |
|---|---|---|---|
| VALIDATION_FAILED | 400 | Missing recipient, unknown category, or template variables that don't match the template's schema — with field-level details. | no |
| TEMPLATE_NOT_FOUND | 404 | Referenced template id or version doesn't exist in this environment — usually a staging/production config drift. | no |
| SCHEDULE_IN_PAST | 422 | `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_STATE | 409 | Canceling a notification already `processing` or `done`. Body carries current state. | no |
| RATE_LIMITED | 429 | Caller exceeded their creation budget — protects downstream providers from one team's runaway loop. `Retry-After` set. | after delay |
| PROVIDER_UNAVAILABLE | 503 | The 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.
202 plus a queryable Delivery record tells the truth about what the platform can actually promise (Long-Running Operations: 202 and the Job Resource).202, and callers wanting confirmation must poll or subscribe — one uniform model was chosen over per-channel special cases.422 USER_OPTED_OUT when all channels are vetoed.(recipient, template, 5min).How it evolves
- • New channel (WhatsApp) is a new
channelvalue 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
frequencysetting; 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_atwithsend_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.