Grokking the System Design Interview, Volume II
Vote

0% completed

​

Step 4: API Design

Four fields, four decisions. Channels and priority are the two that the rest of the design has to honor.
Four fields, four decisions. Channels and priority are the two that the rest of the design has to honor.

The send endpoint below returns 202 Accepted, not 200 OK. That status code states the contract of the whole service: the request is stored and will be attempted, but nobody has been notified yet. Three endpoints cover the product: one sends a notification, one reports what happened, and one changes a user's preferences.

1. Send Notification

  • POST /v1/notifications
  • Headers: Authorization: Bearer <tenant-key>
  • Request Body:
{ "user_id": "u_12345", "template_id": "order_shipped_v1", "channels": ["push", "email"], "priority": "transactional", "idempotency_key": "uuid-gen-by-client", "data": { "order_id": "999", "name": "Alice" } }
  • Response: 202 Accepted
{ "notification_id": "notif_abc123", "channels": ["push", "email"] }
  • Error: 429 Too Many Requests (quota exceeded).

Three fields and two status codes decide how the rest of the design behaves.

channels is an optional list. Step 2 asks for delivery on one or several channels. Leaving the list out means "use whatever this user has enabled". Either way, the service drops any channel the user has switched off before queuing anything.

priority describes urgency and never ranks channels. Step 2 treats channels equally, so email is never served ahead of SMS, but urgency is a separate question. The accepted values are transactional, default and bulk, and Step 7 gives each its own queue per channel, so a one-time passcode is never stuck behind a ten-million-message campaign.

idempotency_key comes from the caller. An idempotency key is a value that makes a repeated request safe to send again. The caller creates one per real-world event and reuses it on every retry of that event. Without it, a caller that retries after a timeout would send Alice the same message twice. The service stores the key with its result for 24 hours, and a repeat receives the original notification_id with nothing new queued.

202 Accepted ends the synchronous part. A caller that needs the outcome either polls the status endpoint below or registers a webhook, a URL the service calls when a notification's status changes.

429 Too Many Requests enforces the tenant's quota. Its Retry-After header states how many seconds to wait, so a well-behaved client slows down instead of retrying at once and making the overload worse.

2. Get Status

  • GET /v1/notifications/{notification_id}
  • Response:
{ "status": "partial", "channels": { "push": { "status": "delivered", "updated_at": "..." }, "email": { "status": "failed", "reason": "hard_bounce" } } }

Status is reported per channel, because Alice's notification can succeed on push and fail on email, and a single top-level status would hide the failure the caller most needs to see. For push, delivered means the push provider accepted the message for delivery to the device, not that Alice has seen it.

3. Update Preferences

  • PUT /v1/users/{user_id}/preferences
  • Body: { "categories": { "marketing": false, "shipping": true } }

Opting out changes the user's own preferences, not one notification, so it applies to every future send from every caller, and no tenant can override it with a request field.

All three endpoints follow one rule: the caller states what it wants, and the service decides what actually goes out.

Reading Progress

0%


Vote for new content