InterviewKata
System design · Module 07 of 29 · Level 2: Building blocks

API design: contracts that are safe to call, retry and change

An API is a promise to everyone who calls it. Once apps depend on it, it's hard to change, so the decisions you make on day one last for years. This module covers how to shape resources and errors, page through long lists without losing items, make retries safe, evolve without breaking clients, and when gRPC or GraphQL fit better than REST.

New to this?Start with B1 · What a system is made of in Level 0; this module assumes it.

1. The idea

A restaurant menu is an API. It names the dishes (resources), says what you can do (order, change, cancel), and what you'll get back. A good menu is consistent ("all mains come with a side"), never changes a dish's meaning without warning, and a good waiter, asked twice for the same order because you weren't sure they heard, brings one meal, not two.

Most public and product APIs are REST: you act on resources (nouns) using a small set of HTTP methods (verbs).

MethodMeaningExampleSafe to repeat?
GETRead; never changes anythingGET /events/42Yes
POSTCreate (server picks the ID), or start an actionPOST /events/42/bookingsNo, unless you add an idempotency key
PUTReplace the whole resourcePUT /users/7/avatarYes
PATCHChange some fieldsPATCH /bookings/9 {"seats": 3}Depends on the change
DELETERemoveDELETE /bookings/9Yes

"Safe to repeat" is idempotency: doing it twice has the same effect as doing it once. It matters because networks fail after the server acted but before the client heard back, so clients retry, and a non-idempotent retry can charge a card twice.

Shaping the request

2. How it works

Paging through a list that keeps changing

Every list that grows (posts, orders, messages) needs pagination. There are two common ways. Press Play to see what happens when new posts arrive while a user scrolls.

-- Offset: simple, lets users jump to page 7, but drifts and gets slow deep down (the DB still walks the skipped rows)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;

-- Cursor (keyset): stable under inserts, fast at any depth with an index on (created_at, id)
SELECT * FROM posts WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;

Return the cursor as an opaque string ("next": "eyJ0IjoiMjAyNi0x...") so you can change what's inside it later. Always cap limit.

Errors that clients can act on

Use the right status class (4xx: the caller must change something; 5xx: our fault, maybe retry) and one error shape everywhere. RFC 9457 ("Problem Details") standardises it:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{"type": "https://api.example.com/errors/seat-taken",
 "title": "Seat no longer available",
 "status": 409,
 "detail": "Seat 14C was booked by someone else.",
 "instance": "/events/42/bookings"}

Making POST safe to retry

The client generates a unique idempotency key (a random UUID) per logical operation and sends it with every attempt. The server stores the key with the result; a repeat of the same key returns the stored result instead of acting again. Stripe popularised this (keys up to 255 characters, kept for at least 24 hours), and the IETF is standardising the Idempotency-Key header (still a draft in October 2026). Try it in section 3.

Long-running work: 202 and a job

If a request starts something slow (a video encode, a report), don't hold the connection open. Return 202 Accepted with a job resource, Location: /jobs/123, and let the client poll it, or call the client back with a webhook when it's done. Sign webhooks (an HMAC of the body with a shared secret) so receivers can verify they came from you, and expect them to arrive more than once.

Changing an API without breaking clients

Nine principles to apply out loud

Don't recite these; show them in the endpoints you write. I'll add an idempotency key here so a retry can't double-book the seat scores; a memorised list doesn't.

  1. Resources, not actions: GET /users/7/bookings, not /getUserBookings.
  2. Consistency: the same names, parameters and response shapes on every endpoint.
  3. Least surprise: GET never changes data; errors are never hidden inside a 200.
  4. Stateless: every request carries its token and inputs, so any server can answer.
  5. Safe retries: idempotency keys on payments, bookings and orders.
  6. Paginate every growing list, with a maximum page size.
  7. Secure by default: authentication unless marked public, a per-resource permission check, rate limits.
  8. Evolve without breaking: add fields freely; rename or remove only in a new version.
  9. Actionable errors: the right status class, a machine-readable code and a human message.

3. Try it yourself

Retry a payment over a flaky network

Each click sends POST /payments {"amount": 50}. With "lose the response" on, the server charges the card but the reply never reaches the app, so the app retries. Try it with and without an idempotency key.

Spot the API smells

4. REST, gRPC or GraphQL?

REST (JSON over HTTP)gRPC (Protocol Buffers over HTTP/2)GraphQL
Best forPublic APIs, web and mobile clients, most product APIsService-to-service calls inside your systemMany different clients needing different slices of rich, connected data
StrengthsSimple, universal, cacheable by CDNs and browsers, easy to debug with curlCompact binary messages, typed contracts and generated clients in many languages, streaming, deadlinesClient asks for exactly the fields it needs in one request; one schema for many teams
WeaknessesOver- or under-fetching; many round trips for nested data; schema is optional (use OpenAPI)Not usable directly from browsers (needs gRPC-Web or a gateway); harder to inspect by handHard to cache at the HTTP layer; the N+1 query problem (fix with batching "dataloaders"); per-field authorization; expensive queries need limits
Interview defaultYes, for anything user-facingMention for internal calls when performance mattersOnly when the prompt stresses varied clients or rapid front-end change

Real-time push (WebSockets, server-sent events) is a different problem: Module 19.

5. In real systems

The technologies up close

The default answer in an interviewREST over HTTPS, JSON, plural resource names, the user from the auth token. List the endpoints for the core requirements, add pagination to every list and an idempotency key to any payment or booking, and move on: about five minutes. Mention gRPC only for internal calls and GraphQL only if the prompt hints at varied clients.

REST over HTTPS

The default · about 90% of answers

Resources named by URLs, acted on with HTTP methods, usually exchanging JSON.

How it works

Plural nouns for resources (/events, /events/42/bookings); path for identity, query string for optional filters, sorting and paging, body for the data. Status codes say what happened; caching headers let browsers and CDNs reuse responses.

Status today

The default for public and product APIs. Described with OpenAPI, which generates docs, clients and validation.

Use it when

  • Every client-facing API unless the prompt pushes elsewhere
  • Most internal APIs too: simple, debuggable with curl

In the interview

I'll use REST. Then list only the endpoints for the core requirements, with their main inputs and outputs. About five minutes, then move on.

🚩 Red flags
  • Verbs in paths (/createBooking, /startGame): model the resource, e.g. PATCH /games/7 {status: started}.
  • Taking the user ID from the body or path for the caller's own data: it comes from the token.
  • Designing every endpoint, header and status code on the whiteboard. The API step is meant to be short.

gRPC

Situational · internal service calls

Remote procedure calls with typed contracts: you define services and messages in a .proto file, and gRPC generates clients and servers in many languages.

How it works

  • Messages are Protocol Buffers: compact binary, with fields numbered so old and new versions interoperate.
  • Runs over HTTP/2: many calls multiplexed on one connection, plus streaming in either or both directions.
  • Deadlines travel with each call through every downstream service, so work stops when the caller has given up.

Status today

A mature CNCF project, widely used between microservices. Browsers can't call it directly: they need gRPC-Web or a gateway that translates from REST or JSON.

Use it when

  • High-volume service-to-service calls where latency and payload size matter
  • Many languages that must share one strict contract
  • Streaming between services

In the interview

One line: Externally REST; internally the services talk gRPC for typed contracts and lower overhead.

🚩 Red flags
  • Offering gRPC to browsers or third-party developers without mentioning gRPC-Web or a gateway.
  • Using it to sound sophisticated in a design with three services. Say why: payload size, latency, typed contracts or streaming.

GraphQL

Situational · rarely needed in interviews

One endpoint and a typed schema; each client sends a query naming exactly the fields it wants, across related objects, and gets that shape back.

How it works

  • The server maps each field to a resolver that fetches it.
  • One request replaces several REST calls, and mobile clients don't download fields they don't use.
  • Naive resolvers cause the N+1 problem: one query for a list, then one per item. A dataloader batches those into one query.

Status today

Mature, with a foundation-run specification, and used by large public APIs such as GitHub and Shopify. Many companies use it as a layer in front of their REST or gRPC services (a "backend for frontend").

Use it when

  • The prompt stresses many different clients needing different data
  • Front-end teams iterating fast on screens that combine many objects

In the interview

Only if the interviewer hints at varied clients or over-fetching: A GraphQL layer lets each client fetch exactly its fields; we'd use dataloaders to avoid N+1 queries and limit query cost.

🚩 Red flags
  • Choosing it by default. It adds complexity the interviewer will ask you to justify.
  • Not knowing its costs: N+1 queries, harder HTTP caching, field-level authorization, and expensive queries that need depth or cost limits.
  • Claiming it's faster. It can save round trips, but each request does more work on the server.

Tokens (JWT) and API keys

The default · one sentence

How a request proves who it's from. Authentication says who you are; authorization says what you may do with this resource.

How it works

  • End users: after login (often OAuth 2.0 / OpenID Connect, Module 26), the client sends a short-lived signed token, usually a JWT carrying the user ID, roles and expiry. Any service can verify the signature without a database call.
  • Server-to-server and third-party developers: API keys (or the service's own credentials), never for end users.
  • Authorization: after verifying the token, check permission on this resource; roles (RBAC) decide which endpoints each kind of user may call.

Status today

Universal. A server-side session in a database is equally fine for web apps; JWTs win when many services must verify identity independently.

Use it when

  • Every non-public endpoint
  • JWT when several services verify identity
  • API keys for partner integrations

In the interview

Requests carry a JWT; the gateway verifies it, and each endpoint checks the caller owns the resource.

🚩 Red flags
  • Authentication without authorization: checking the token but not that booking 9 belongs to this user is the most common real API breach.
  • API keys for end users, or secrets inside a JWT (its payload is readable by anyone).

Rate limiting

The default · one sentence unless asked

Capping how many requests a user, API key or IP may make in a period, to protect the system and share it fairly.

How it works

Usually enforced at the API gateway with a token bucket or sliding window kept in a shared store such as Redis (Module 24, Design D02). Over the limit, return 429 Too Many Requests with Retry-After.

Status today

Built into every API gateway and most CDNs; Design D02 builds one from scratch.

Use it when

  • Public APIs, login endpoints, expensive operations
  • Per-tenant fairness in multi-tenant systems

In the interview

One sentence: The gateway rate-limits per user and per IP and returns 429.

🚩 Red flags
  • Designing a rate limiter in detail when the question is about something else; it eats the time for the real deep dives.

API versions over time: compatibility, deprecation and sunset

The default answer for "how do you change the API?"

An API is a promise to code you don't control. Most changes should be additive and need no version at all; the rare breaking change gets a new version, a deprecation notice, a migration window and a published shutdown date.

How it works

  • First, avoid needing one: additive changes (new endpoints, optional fields, new response fields) are safe as long as clients ignore unknown fields; only the breaking changes listed earlier on this page need a new version.
  • Three ways to number versions: in the path (/v2/orders, the most common, easy to route and cache); in a header; or by date. GitHub's REST API uses X-GitHub-Api-Version: 2026-03-10, defaults to 2022-11-28 when no header is sent, and supports the previous version for at least 24 months after a new one. Stripe pins each account to a dated version (override with the Stripe-Version header); since 2024 it ships monthly non-breaking versions and twice-yearly major releases with names, such as 2024-09-30.acacia.
  • Telling clients: the Deprecation response header (RFC 9745, March 2025) says when an endpoint was or will be deprecated, and the Sunset header (RFC 8594) says when it will stop responding. Sunset must not be earlier than Deprecation. Add a Link to the migration guide, email the owners of the API keys still calling it, and watch the old version's traffic fall.
  • Behind the scenes: keep one current internal model and translate old versions at the edge (Stripe describes this as version-change modules applied in reverse), so you don't maintain two copies of the business logic.
StageWhat clients see
v2 launchedv1 still works; docs point to v2
v1 deprecatedDeprecation and Sunset headers on every v1 response; emails to callers
Brown-outs (optional)v1 fails for short planned windows so forgotten callers notice
Sunset datev1 returns 410 Gone with a link to the guide

Status today (checked October 2026)

Path versioning is the most common for public REST APIs; date-based header versions are used by GitHub and Stripe. gRPC and GraphQL APIs usually evolve without versions by only adding fields and deprecating old ones in the schema. Checked against the GitHub REST API versions page, Stripe's versioning docs and RFC 9745 (October 2026).

Use it when

  • Any public or partner API, where you can't update the clients yourself
  • Mobile apps, where old versions stay installed for years

In the interview

New fields are additive, so no version bump. If I must break something, I'll add /v2, put Deprecation and Sunset headers on v1 with at least a year's notice, and track v1 traffic per API key until it reaches zero.

🚩 Red flags
  • A new version for every change: additive changes don't need one.
  • Removing a field "because nobody uses it" without measuring.
  • No sunset date, so old versions live forever.

6. Remember it

Memory hook: "Nouns, verbs, and a receipt"Resources are nouns, methods are verbs, and every important write carries a receipt number (an idempotency key) so asking twice can't buy twice.
How this hook works: what "Nouns, verbs, and a receipt" means

What the slogan means, phrase by phrase

"Nouns"
Resources in the path, plural and nested by ownership: /users/7/bookings, not /getUserBookings.
"verbs"
The HTTP methods, with their standard meanings: GET reads, POST creates, PUT replaces, PATCH changes, DELETE removes.
"a receipt"
An idempotency key on non-idempotent writes, so retries are safe. The receipt idea extends to cursors (a bookmark into a list) and ETags (a version stamp).

Why this picture fits: a shop counter works the same way: you name the item, say what you want done, and keep a receipt so a dispute or a repeat doesn't charge you twice.

The story → the concept

At the counterIn the API
The item on the shelfA resource and its URL
"I'd like to buy / return / swap"POST / DELETE / PUT or PATCH
The receipt numberThe idempotency key
"Show me the next ten after this one"A pagination cursor
The price tag's dateAn ETag version for safe updates
The shop's printed rulesThe OpenAPI schema and error format

Watch it on an example

Designing a ticket booking API:

  1. Nouns: /events, /events/{id}/seats, /bookings.
  2. Verbs: GET /events?city=Pune&cursor=…, POST /bookings, DELETE /bookings/{id}.
  3. Receipt: POST /bookings requires an Idempotency-Key, so a double-tap or a retry books once.
  4. The user comes from the token; the server checks the booking belongs to them before a DELETE.

The trap it saves you from

Action-shaped endpoints (/bookTicketForUser?id=7) and retry bugs that double-charge, both of which are painful to fix once clients depend on them.

It still works when…

You're designing messages for a queue (Module 18). Name the event as a noun-plus-past-tense ("BookingCreated"), and give it an ID consumers can use to ignore duplicates: the same receipt idea.

7. Common mistakes

Trusting a user ID in the requestDELETE /bookings/9?userId=7 lets anyone delete anyone's booking. Identify the caller from the token, and check ownership.
Unbounded listsAn endpoint that returns "all orders" works in testing and times out in production. Paginate every list and cap the page size.
Errors inside a 200200 OK {"error": "not found"} breaks every client library, cache and monitor. Use the right status code.
Retrying non-idempotent writes blindlyRetries without an idempotency key turn a network blip into duplicate orders.
Breaking changes without a versionRenaming a field breaks old app versions that users haven't updated. Add, don't change; version when you must.

8. Design exercise (25 minutes)

  1. Design the API for a food delivery app: browse restaurants near me, view a menu, place an order, track the order. Write each endpoint with method, path, key inputs and response.
  2. Make it safe: which endpoints need an idempotency key? Which lists need pagination, and offset or cursor?
  3. Errors: write the response for "restaurant closed" and for "payment declined". Which status codes?
  4. Evolve it: the product now wants scheduled orders. Add it without breaking existing clients.
  5. Write it down: your three favourite rules from this page in your design notebook.

9. Self-check quiz

Which is the most RESTful way to list a user's orders?

A plural noun nested under its owner, read with GET. (The server still checks the caller may see user 7's orders.)

An infinite-scroll feed gets new posts every second. Which pagination keeps items from repeating?

A cursor remembers where you stopped, so inserts at the top don't shift what "the next page" means.

A client retries POST /payments with the same idempotency key. What should happen?

That's the whole point of the key: the server recognises the repeat and replays the stored result.

Two internal services exchange thousands of small typed messages per second. Which fits best?

Compact binary encoding, generated typed clients, HTTP/2 multiplexing and deadlines make gRPC the usual internal choice.

The prompt is a simple booking app with web and mobile clients. Which API style do you pick?

REST is the default for client-facing APIs. GraphQL needs a reason (varied clients, over-fetching) and brings costs you'd have to defend.

Your GraphQL server lists 50 posts and their authors, and the database receives 51 queries. What's this, and the fix?

Each author resolver runs its own query. A dataloader collects them into one batched lookup.

You add an optional field to an API response. Do you need a new API version?

Adding is safe; removing, renaming or changing meaning is what breaks clients.

Which HTTP response header tells clients the date an endpoint will stop working?

Sunset (RFC 8594) gives the shutdown date; Deprecation (RFC 9745) gives the deprecation date, which must not be later than it.

10. Go deeper

ResourceTimeWhy
Idempotent requests (Stripe API docs)10 minThe reference implementation of idempotency keys that most APIs copy.
API design guide (Google Cloud)1 hResource-oriented design, standard methods, errors and versioning, from a company with thousands of APIs.
RFC 9457: Problem Details for HTTP APIs20 minThe standard error format, short and readable.
Learn GraphQL and gRPC deadlines45 minThe official introductions to the two alternatives.

11. Under the hood (optional)

How API payloads are really encoded, evolved and compressed. Not needed for most interviews; open it when you want to know how the products really do it. All the deep dives are listed on the Under the hood page.

Data formats and compressionA live JSON vs Protobuf vs gzip size lab, schema evolution rules for JSON, Protobuf, Avro and friends, codecs from gzip to zstd and LZ4about 20 min

Every API and message has two hidden choices: how data becomes bytes (the format) and how those bytes are squeezed (compression). They decide payload size, CPU cost, and whether old and new versions of a service can still talk to each other.

What a payload really costs

A list of order records, encoded as JSON and as Protobuf, each with and without gzip. The gzip sizes are computed by your browser's own compressor; the Protobuf bytes follow the real encoding:

Formats and schema evolution

FormatSelf-describing?How it evolvesWhere
JSONYes (field names in every record)Ignore unknown fields; never change a field's meaning or typePublic APIs, configuration, logs
MessagePack, CBORYes, binaryLike JSON, smaller and faster to parseCaches, IoT (CBOR is an IETF standard), Redis clients
Protocol BuffersNo: numbered fields; the schema maps numbers to namesAdd fields with new numbers; never reuse or change a number (mark removed ones reserved); unknown fields are kept and passed throughgRPC, Google, internal services
AvroNo: values only, in schema orderThe reader resolves the writer's schema against its own (defaults fill missing fields), so the writer's schema must travel with the data or sit in a schema registryKafka with a schema registry, Hadoop data files
ThriftNo: numbered fieldsLike ProtobufOlder RPC stacks (Meta, Apache projects)
  • Backward compatible = new code reads old data; forward compatible = old code reads new data. Rolling deploys need both, because old and new versions run side by side.
  • Safe changes: add an optional field with a default. Unsafe: rename (in JSON), renumber (in Protobuf), change a type, make an optional field required.

Compression

CodecStrengthWhere
gzip / DEFLATESupported everywhere; moderate ratio and speedHTTP default, files
BrotliBetter ratio than gzip for text, slower to compress at high levels; has a built-in web dictionaryStatic web assets (all modern browsers)
Zstandard (zstd)gzip-like or better ratios at much higher speed; tunable levels; trained dictionaries for small messagesKafka, databases, file systems; HTTP in Chrome and Firefox since 2024 (check Safari before relying on it)
LZ4, SnappyVery fast, lower ratioDatabases, RPC, Kafka, Parquet pages
  • The trade-off is CPU vs bytes: compress across the internet and on mobile, where bandwidth and latency dominate; inside a data centre, a fast codec or none may be cheaper.
  • Small messages compress poorly because there's little repetition to find; batching (Kafka compresses whole batches) or a shared dictionary fixes that.
  • Don't compress twice: images, video and already-compressed files gain nothing and waste CPU. And never compress secrets together with attacker-controlled data in one response (the BREACH attack class).

Say it in the interview

Answers that show depth
  • "JSON or Protobuf?" JSON at the public edge for reach and debuggability; Protobuf (gRPC) between services for size, speed and enforced schemas. With gzip, JSON's size penalty shrinks; the parsing cost doesn't.
  • "How do you change a message without breaking consumers?" Only add optional fields with defaults, never reuse field numbers, and check compatibility in CI with a schema registry.

What is the one rule you must never break when evolving a Protobuf message?

Old data and old code identify fields by number; a reused number is silently misread.

Why does compressing each tiny message separately save little?

Batch messages, or use a trained dictionary (zstd).

Sources