API Design
Design endpoints the way interviewers expect: REST resources, cursor pagination, idempotency keys, versioning, errors, and when RPC or events fit.
On this page
A diner taps "Book" for 4 people at 19:30. The server books a table, but the phone loses signal before the reply arrives. The app sees a timeout and retries, and the server books a second table. The restaurant keeps one empty all evening. If 0.5% of bookings succeed but lose their reply and get retried, the reservation system from requirements and scope, with 1 million bookings a day, makes 1,000,000 × 0.005 = 5,000 duplicate bookings a day.
That is an API design bug. An API is a contract with clients you cannot update all at once, so settle the parts that are expensive to change first: resources, paging, retries, errors and versions.
Resources and methods
Start from the functional requirements: each one becomes an endpoint. In REST, URLs name things (nouns) and HTTP methods say what you do to them. Spend about 3 minutes on this in an interview, as in the framework.
GET /v1/restaurants/{id}/availability?date=2026-12-04&party_size=4
200 { "slots": [ { "time": "19:00", "tables_free": 2 }, ... ] }
POST /v1/reservations Idempotency-Key: 6f1c...e2
body { "restaurant_id": 42, "time": "...", "party_size": 4 }
201 { "id": "r-5521", "status": "confirmed" }
409 slot_taken 422 invalid_party_size 429 rate_limited
GET /v1/reservations/{id} 200 | 404
DELETE /v1/reservations/{id} 204 | 404
GET /v1/users/me/reservations?limit=20&cursor=...
200 { "items": [ ... ], "next_cursor": "eyJ0IjoiMjAyNi0..." }The client asks for a time and party size; the server picks a free table that fits. That is why a blind retry books a second table instead of failing.
Method semantics matter for retries. GET changes nothing, so it is always safe to repeat. PUT and DELETE are idempotent by definition: doing them twice leaves the same state as once (the second DELETE may return 404, but nothing new happens). POST creates something new each time, so repeating it is the dangerous case.
Idempotency keys
The fix for the double booking: the client generates a unique key per user action (a random UUID when the diner taps "Book") and sends the same key with every retry of that action. The server remembers each key with the response it produced, and answers a repeat with the stored response instead of booking again.
- key
- 6f1c
- bookings
- 0
1The diner taps Book. The app creates key
6f1cfor this action and sends it in theIdempotency-Keyheader.- key
- 6f1c
- bookings
- 1
2In one transaction the API inserts the key (only if absent), books table 7 and stores the response it will send. One transaction means a crash leaves either all three or none.
- key
- 6f1c
- bookings
- 1
3The reply is lost on the way back. The app only sees a timeout: it cannot know whether the booking happened.
- key
- 6f1c
- bookings
- 1
4The app retries with the same key and the same body. A new key here would book a second table.
- key
- 6f1c
- bookings
- 1
5The key insert finds
6f1calready there with a stored response. Nothing is booked.- key
- 6f1c
- bookings
- 1
- status
- 201
6The API replays the stored
201withr-5521. The diner sees one booking; the restaurant holds one table.
Details interviewers check. A repeat that arrives while the first request is still running must not run in parallel: the key row is claimed first, so the second request waits or gets a "still in progress" error to retry. The same key with a different body is a client bug: reject it with 422. Keys are kept for a window longer than any client retries (24 hours is a typical choice) and then deleted.
Be precise about what this buys. The network can still deliver the request twice; the key makes the effect happen once. That only holds if the key and the booking commit together. More in idempotency and retries.
Pagination
A diner's reservation history is a list that grows. Never return it all; return pages of, say, 20. There are two ways to say "the next page".
Offset: ?offset=20&limit=20, which the database runs as "sort, skip 20, take 20". Two problems. If a new reservation is added while the diner pages, everything shifts down by one: the last item of page 1 appears again at the top of page 2. And the database still reads every skipped row, so offset=100000 reads 100,020 rows to return 20.
Cursor: the response includes next_cursor, an opaque token encoding the sort key of the last item returned, for example its (created_at, id). The next query asks for items after that point, which an index answers directly, and inserts elsewhere in the list do not shift anything.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Offset and limit | Simple. Lets a user jump to page 37. | Items repeat or vanish when the list changes. Deep pages get slower, because skipped rows are still read. | Small, rarely changing lists, or an admin table that needs page numbers. |
| Cursor Usual default | Stable while the list changes. Every page costs the same, through an index. | No jumping to page N. The sort order must have a unique tiebreaker such as the id. | Feeds, histories, any list that grows. The usual interview answer. |
Errors and versioning
Use the status code for the category and a body for the detail: {"error": {"code": "slot_taken", "message": "Table 7 was just booked", "retryable": false}}. A machine-readable code lets the app react (show other times), and retryable tells clients which failures to retry. 4xx means the client must change something; 5xx and 429 may succeed later, with backoff.
Adding a field is safe; clients ignore what they do not know. Removing or renaming a field, or changing what one means, breaks clients that may live on phones for years. Put a major version in the path (/v1/) for a public API: it is visible in logs and easy to route. Run old and new side by side until traffic on the old one fades.
When REST is the wrong shape
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| REST over HTTP Usual default | Understood by every client and tool. GETs can be cached by browsers and CDNs. | Loose contracts unless you add a schema. Chatty for complex reads. | Public and mobile APIs. The default. |
| RPC (gRPC, for example) | Typed, generated client code and compact binary messages. | Poor fit for browsers without a proxy. Harder to inspect by hand. | Internal service-to-service calls with many methods and high volume. |
| Events on a queue | The sender does not wait and does not know who listens. Consumers can be added freely. | No direct reply. Ordering and duplicates must be handled by consumers. | Something happened that others react to: a booking triggers an email and analytics. |
For pushing updates to a client (a table frees up), none of these is enough on its own; see realtime communication.
In the interview
Write the 3 to 5 endpoints for the core flows, with error cases, then argue the one decision that matters most for this problem. Phrases that show judgment:
- "Booking is a POST that clients will retry on bad networks, so it takes an idempotency key."
- "History grows and changes while you scroll, so I'll page it with a cursor."
- "Public clients get REST; the booking service tells the email service through an event, because it should not wait for an email."
Why not just check for a duplicate reservation before inserting?
The original and a retry can both check, both find nothing, and both insert. And "same diner, same time" is not always a duplicate: someone might book for two groups. The key says exactly which request is a retry. It's stored in the same transaction as the booking, so there is no gap.
Follow-ups you should expect:
- "Who generates the key?" The client, once per user action, before the first attempt. A key generated by the server cannot recognize a retry.
- "How do you rate limit this API?" Per user or API key, returning 429 with a
Retry-Afterheader. See rate limiting. - "Would you use GraphQL?" When many client screens need different shapes of the same data. For a few fixed flows, REST is simpler to cache and secure.
Common mistakes
A history list is paged with offset, 20 per page. A diner reads page 1, then a new reservation is added at the top, then they load page 2. What do they see?
Which request is safe for a client to retry with no extra mechanism?
A list is sorted by an indexed column. With offset=100000&limit=20, about how many rows does the database read?
What to practice next
The endpoints decide the queries; Data Modeling turns those queries into tables and keys. For an API where idempotency is the whole game, see Design a Payment System.