An API is the contract between a service and everything that calls it: mobile apps, other microservices, partners, and now LLM agents that read your schema and generate calls to it. Interviewers probe API design because it is where small decisions become permanent. A field name, a pagination style, or a status code that you ship on day one is copied into client code you do not control, and changing it later means a breaking change and a migration measured in quarters. For a Backend or AI engineer the surface is wider than classic CRUD: model endpoints take seconds to minutes instead of milliseconds, stream partial results, cost money per call, and are retried by clients that cannot tell whether the first attempt already spent the tokens.
This page covers the fundamentals (style choice, resource modelling, pagination, idempotency, errors, versioning, async and streaming) and then applies them to an LLM endpoint. For the wire-level basics see HTTP fundamentals, and for the protocol trade-off see REST vs gRPC between microservices.
An API (Application Programming Interface) is the set of rules that lets one piece of software ask another piece of software to do something — like asking a server to fetch a user's order history or process a payment. API design is the practice of deciding how those requests and responses should look: what to name things, how to structure the data, what happens when something goes wrong, and how the contract can change over time without breaking every app that depends on it.
Think of a restaurant menu. The menu does not show you how the kitchen cooks each dish — it just tells you what you can order, what it costs, and what you will get back. You place an order (a request), the kitchen does the work out of sight, and a waiter brings you a plate (the response) in a predictable, described format. A well-designed API is like a clear, well-organized menu: it tells any client exactly what it can ask for and what it will get back, without exposing how the kitchen actually works.
Style Unit of design Transport / format Strong at Painful at
-------- -------------------- ---------------------------- --------------------------- ----------------------------
REST resource (noun) HTTP + JSON (usually) caching, tooling, public chatty for aggregated UIs,
GET /orders/42 APIs, debuggability actions that are not CRUD
RPC/JSON action (verb) HTTP POST + JSON "do X" operations no uniform semantics, no
POST /rerank built-in cacheability
gRPC service + method HTTP/2 + protobuf internal calls, typed browsers need grpc-web,
Rerank(req) -> resp (binary), streaming stubs, streaming, small hard to curl and to cache
payloads at the HTTP layer
GraphQL typed query graph usually one POST endpoint UIs that need many N+1 resolvers, cost control,
{ user { orders } } JSON entities in one round trip HTTP caching is harder
Resource modelling, order service:
GOOD POST /v1/orders create
GET /v1/orders/{id} read one
GET /v1/orders?status=open list + filter (with pagination)
PATCH /v1/orders/{id} partial update
POST /v1/orders/{id}/cancel action that is not CRUD: model as a sub-resource verb, or
POST /v1/orders/{id}/refunds ... better, create a "refund" resource so it has an id + state
BAD POST /v1/getOrderAndItemsAndUser (RPC verb in the path, no reuse, uncacheable)
REST is not "JSON over HTTP"; it is a way to model the domain as addressable resources and use the uniform HTTP verbs so that intermediaries (CDNs, proxies, browsers) can reason about the traffic without knowing your domain. The verb semantics are the real value: GET is safe and cacheable, PUT and DELETE are idempotent by definition, POST is neither, and PATCH is not guaranteed idempotent. When an operation does not fit CRUD (cancel, retry, rerank, run inference), do not contort it. Either model the result as a resource (a refund, a job, a completion with an id) or accept an RPC-shaped POST honestly. Most model-serving endpoints are RPC-shaped because "run this model on this input" is an action, not a noun.
Choose by who the caller is. Public or partner APIs favour REST because every language and tool speaks it, and you cannot make strangers install your protobuf toolchain. Internal service-to-service traffic favours gRPC when you want generated typed clients, small binary payloads, deadlines and bidirectional streaming (many model-serving frameworks expose gRPC for exactly this reason). GraphQL earns its cost when one screen aggregates many entities and mobile bandwidth matters, but it moves control to the client: you now need query depth and cost limits, dataloader batching against N+1 queries, and often persisted queries, because one HTTP endpoint gives you little to rate-limit or cache on. Real systems mix them: gRPC inside, REST at the edge, sometimes GraphQL as a backend-for-frontend.
Feed sorted newest-first. Rows change while the client pages.
OFFSET / LIMIT CURSOR (keyset)
GET /posts?offset=4&limit=4 GET /posts?limit=4&after=eyJpZCI6N30 (opaque)
SQL: ORDER BY id DESC LIMIT 4 OFFSET 4 SQL: WHERE id < 7 ORDER BY id DESC LIMIT 4
engine reads and discards 4 rows first engine seeks the index to id=7, reads 4 rows
cost grows with the offset: O(offset+limit) cost ~ O(log n + limit), independent of depth
new rows shift every later page: duplicates position is a VALUE, not a count: stable
or skips; deletes cause skips under inserts and deletes
can jump to "page 50", cheap total count no jump-to-page; total count is a separate query
Illustrative cost (assume no better index tricks): OFFSET 900000 LIMIT 20 walks about
900,020 index entries to return 20; the cursor form walks about 20.
Sort on a non-unique column (created_at)? Add a unique tie-breaker:
ORDER BY created_at DESC, id DESC and WHERE (created_at, id) < (:ts, :id)
Offset pagination is a promise about position ("skip the first N rows"), and position is exactly what changes when the data is live. If two posts arrive between page 1 and page 2, every row shifts down by two and the client sees the last two rows of page 1 again. If rows are deleted, the client silently skips some. It is also slower by construction: the database generally cannot jump to row N, it has to step over the N rows it then throws away, so the cost of a deep page grows with the offset. Keyset (cursor) pagination replaces "skip N" with "give me rows after this key", which is stable under writes and lets the database use an index seek. The price is real: no random access to page 50, no cheap total count, and the sort key must be a total order, which is why a non-unique sort column needs an id tie-breaker.
Return the cursor as an opaque token (for example base64 of the sort key values) plus next_cursor: null when done, so you can change its internals later without breaking clients; never let clients construct cursors. Offset is still fine for small, mostly static lists and admin tables where users really do jump to page N. Cap limit on the server, and for search or ranked results, where the order is a relevance score that can shift between requests, say plainly that you either snapshot the result set behind a server-side cursor or accept some drift. The demo below runs against an in-memory SQLite database.
# Offset vs cursor pagination while new rows arrive (newest-first feed)
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
db.executemany("INSERT INTO posts VALUES (?, ?)", [(i, f"post-{i}") for i in range(1, 11)]) # ids 1..10
def page_offset(offset, limit=4):
q = "SELECT id FROM posts ORDER BY id DESC LIMIT ? OFFSET ?"
return [r[0] for r in db.execute(q, (limit, offset))]
def page_cursor(after_id, limit=4):
if after_id is None:
q, args = "SELECT id FROM posts ORDER BY id DESC LIMIT ?", (limit,)
else:
q, args = "SELECT id FROM posts WHERE id < ? ORDER BY id DESC LIMIT ?", (after_id, limit)
rows = [r[0] for r in db.execute(q, args)]
return rows, (rows[-1] if rows else None)
print("offset page 1:", page_offset(0))
db.executemany("INSERT INTO posts VALUES (?, ?)", [(11, "x"), (12, "y")]) # 2 new posts arrive
print("offset page 2:", page_offset(4), " <- 8 and 7 are repeated")
db.execute("DELETE FROM posts WHERE id IN (11, 12)") # reset
rows, cur = page_cursor(None)
print("cursor page 1:", rows, "next_cursor =", cur)
db.executemany("INSERT INTO posts VALUES (?, ?)", [(11, "x"), (12, "y")])
rows, cur = page_cursor(cur)
print("cursor page 2:", rows, "next_cursor =", cur, " <- no repeats, no gaps")
# Real output from python3:
# offset page 1: [10, 9, 8, 7]
# offset page 2: [8, 7, 6, 5] <- 8 and 7 are repeated
# cursor page 1: [10, 9, 8, 7] next_cursor = 7
# cursor page 2: [6, 5, 4, 3] next_cursor = 3 <- no repeats, no gaps
Client Network Server
POST /v1/charges ---------------------------> charge created (side effect happens)
Idempotency-Key: k-1
<---- response lost / timeout ----
(client cannot know whether it ran)
POST /v1/charges ---------------------------> key k-1 seen -> return the SAVED response,
Idempotency-Key: k-1 do NOT charge again
Server-side record per key: (key, request fingerprint, state: in_progress | done, saved status+body, expiry)
same key + same body -> replay saved response
same key + different body -> 422 (client bug)
same key while first still running -> 409, or wait (two concurrent requests must not both execute)
Status code map an interviewer expects:
400 malformed 401 not authenticated 403 authenticated but not allowed 404 not found
409 conflict 422 semantically invalid 429 rate/quota limited (+ Retry-After)
5xx server fault: 500 bug, 502/503/504 upstream or overload -> retryable with backoff + jitter
4xx (except 408/429) generally NOT retryable: the same request will fail the same way
Networks make "did it run?" unanswerable: a timeout means the client does not know if the server acted, so a well-behaved client retries, and without protection the retry charges the customer twice or generates the completion twice (and pays for the tokens twice). HTTP already gives you idempotent methods (GET, PUT, DELETE), but the important operations are POSTs, so the widely used convention is a client-generated Idempotency-Key header (Stripe popularised it; an IETF draft describes it). The server stores the result under that key and replays it. Two details separate a good answer from a naive one: the key must be bound to a fingerprint of the request body so reuse with different content is rejected, and the "check then execute" step must be atomic (a unique constraint or an insert-if-absent), otherwise two concurrent retries both pass the check. This gives at-least-once delivery from the client plus deduplication at the server, which is effectively-once behaviour; it is not exactly-once delivery. See Idempotency and exactly-once semantics.
The error model matters as much as the success model, because clients write control flow against it. Use the HTTP status for the class of failure, and put a stable, machine-readable code in a consistent body (for example {"error": {"type": "rate_limit", "code": "tokens_per_minute", "message": "...", "request_id": "req_123", "retry_after_s": 12}}; RFC 9457 application/problem+json is the standard shape). Never make clients parse the human message. Include a request id you also log, so a customer can quote it and you can find the trace. Distinguish retryable from non-retryable explicitly, or clients will retry validation errors forever and turn a small incident into a retry storm. See rate limiting for the 429 side.
Pattern A: async job (202 Accepted) Pattern B: streaming (SSE) on the same request
POST /v1/jobs (+ Idempotency-Key) POST /v1/chat {"stream": true}
<- 202 Accepted <- 200, Content-Type: text/event-stream
Location: /v1/jobs/j_9 id: 0 event: token data: {"text":"Hel"}
Retry-After: 2 id: 1 event: token data: {"text":"lo"}
GET /v1/jobs/j_9 -> 200 {"state":"running"} ...
GET /v1/jobs/j_9 -> 200 {"state":"succeeded", event: done data: {"finish_reason":"stop","usage":{...}}
"result_url": "..."}
or webhook: POST customer.example/hook (signed, retried, at-least-once)
Little's law, ILLUSTRATIVE (assume 50 requests/s, each generation runs 20 s):
concurrent open requests = arrival rate x duration = 50 x 20 = 1,000 held connections
A sync request/response API with a 30 s gateway timeout is already close to the edge.
Anything that can outlast a normal HTTP timeout (batch inference, fine-tuning, document processing, video generation) should not be a synchronous call: the load balancer, the client, or a proxy in between will cut the connection, and a retry then starts the work again. The async pattern returns 202 Accepted immediately with a job resource, and the client either polls (simple, works everywhere, wastes some requests, so honour a Retry-After hint) or receives a webhook (efficient, but you now own delivery: sign the payload with an HMAC and a timestamp, retry with backoff, include an event id, and document that delivery is at-least-once so receivers must deduplicate). Make job creation itself idempotent, give the job a small explicit state machine (queued, running, succeeded, failed, cancelled), and offer a cancel endpoint, because an abandoned GPU job is real money. See synchronous vs asynchronous processing and message queues for the machinery behind the job.
For interactive LLM responses the user cares about time to first token, so the server streams tokens as they are decoded. Server-Sent Events (SSE) is the common choice: a normal HTTP response with Content-Type: text/event-stream, one-directional, text framed as field: value lines with a blank line between events, automatic reconnect and a Last-Event-ID mechanism in the browser EventSource (which itself supports only GET, so many LLM APIs use POST with fetch and read an SSE-formatted body). WebSockets are needed only when the client must also send a live stream, such as voice. Streaming has sharp edges you should name: the HTTP status is committed as 200 before generation finishes, so a mid-stream failure must be signalled with an in-band error event; reverse proxies may buffer the stream unless configured not to (for nginx, proxy_buffering off or an X-Accel-Buffering: no header); HTTP/1.1 browsers cap connections per host, which HTTP/2 multiplexing relieves; and when the client disconnects the server should notice and cancel decoding so the GPU stops working for nobody. The final event should carry finish_reason and token usage so billing and clients can reconcile. See streaming responses.
Non-breaking (additive) Breaking
new OPTIONAL request field remove or rename a field
new response field change a field's type or units
new endpoint make an optional field required, tighten validation
new error code in an existing class change a default (temperature, max_tokens, sort order)
new enum value* (*breaks strict clients) change semantics behind the same name
Versioning styles: URL (/v1/...) | header (Accept: application/vnd.x.v2+json) |
date-pinned version per account (e.g. 2026-01-15) | schema evolution (protobuf field numbers)
LLM chat endpoint, the parts worth designing on purpose:
request : model (pinned snapshot vs moving alias), messages, max_tokens, stream, stop, temperature/top_p,
tools, user/tenant id, metadata + Idempotency-Key header
response: id, model (what actually served it), output, finish_reason (stop | length | tool_call | filter),
usage {input_tokens, output_tokens} + request id header
limits : per-tenant requests/min AND tokens/min, remaining/reset headers, 429 + Retry-After
lifecycle: Deprecation and Sunset headers, a published model deprecation schedule
Compatibility is a discipline more than a mechanism. Design for the tolerant reader: clients ignore unknown fields, servers accept unknown optional ones, and you evolve only by adding. Once a contract is public, the cheapest correct move is almost always to add a new field or endpoint and deprecate the old one on a published schedule (the Deprecation and Sunset response headers exist for this) rather than to cut a new major version, because each live version is a codebase you must keep testing and securing. URL versioning is the most visible and easiest to route; date-pinned versions per account let a provider change behaviour without moving every customer at once; protobuf and similar schema formats build evolution rules into field numbers.
LLM endpoints add a category of change that no schema diff catches: the same request can return different text after a model update, and prompts tuned against one model snapshot can degrade on the next. So expose the model as an explicit parameter, prefer pinned snapshots for production callers with aliases only for experiments, echo back which model actually answered, and treat a model retirement as a breaking change with a notice period. Express limits in tokens as well as requests, since one request can be 100 tokens or 100,000, and return the remaining budget in headers so well-behaved clients can pace themselves. Per-tenant quotas belong at the gateway (see designing an LLM API gateway and authentication and authorization for tying keys to tenants).
POST when the operation is an action, as most model calls are.job, a refund) so they have state and can be polled, cancelled and audited.POST that spends money or tokens needs an idempotency key, bound to a request fingerprint, with an atomic insert so concurrent duplicates cannot both run. This is effectively-once, not exactly-once.202 Accepted plus a job resource (poll or signed webhook); interactive generation should stream (SSE) so time to first token is low, and must handle mid-stream errors in-band and cancel on disconnect.finish_reason, and enforce per-tenant quotas at the gateway.POST, accept an idempotency key; store (key, request fingerprint, status, response) atomically and replay on a repeat, reject on a mismatch.202 with a job and a poll or webhook completion path; always define cancel.Idempotency-key handling with a dict standing in for the database (a real one needs an atomic insert or unique constraint):
import hashlib, json
store = {} # Idempotency-Key -> (request_fingerprint, status, body)
charges = []
def fingerprint(body): return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:8]
def post_charge(key, body):
fp = fingerprint(body)
if key in store:
old_fp, status, resp = store[key]
if old_fp != fp:
return 422, {"error": "idempotency_key_reused_with_different_body"}
return status, {**resp, "replayed": True}
charges.append(body["amount"]) # the side effect
resp = {"charge_id": f"ch_{len(charges)}", "amount": body["amount"]}
store[key] = (fp, 201, resp)
return 201, resp
print(post_charge("k-1", {"amount": 500}))
print(post_charge("k-1", {"amount": 500})) # client retry after a timeout
print(post_charge("k-1", {"amount": 900})) # same key, different body
print(post_charge("k-2", {"amount": 500})) # genuinely new request
print("side effects executed:", charges)
# Real output from python3:
# (201, {'charge_id': 'ch_1', 'amount': 500})
# (201, {'charge_id': 'ch_1', 'amount': 500, 'replayed': True})
# (422, {'error': 'idempotency_key_reused_with_different_body'})
# (201, {'charge_id': 'ch_2', 'amount': 500})
# side effects executed: [500, 500]
Server-Sent Events framing for a token stream, plus a minimal client-side parse:
import json
def sse_frames(tokens, request_id):
for i, t in enumerate(tokens):
yield f"id: {i}\nevent: token\ndata: {json.dumps({'text': t})}\n\n"
yield f"event: done\ndata: {json.dumps({'request_id': request_id, 'finish_reason': 'stop', 'output_tokens': len(tokens)})}\n\n"
for frame in sse_frames(["Hel", "lo", "!"], "req_42"):
print(repr(frame))
# Client-side parse: split on blank line, read "field: value" lines
buf = "".join(sse_frames(["Hel", "lo", "!"], "req_42"))
events = []
for block in buf.split("\n\n"):
if not block: continue
ev = {}
for line in block.split("\n"):
k, v = line.split(": ", 1); ev[k] = v
events.append(ev)
text = "".join(json.loads(e["data"])["text"] for e in events if e["event"] == "token")
print("assembled:", text, "| last event:", events[-1]["event"])
# Real output from python3:
# 'id: 0\nevent: token\ndata: {"text": "Hel"}\n\n'
# 'id: 1\nevent: token\ndata: {"text": "lo"}\n\n'
# 'id: 2\nevent: token\ndata: {"text": "!"}\n\n'
# 'event: done\ndata: {"request_id": "req_42", "finish_reason": "stop", "output_tokens": 3}\n\n'
# assembled: Hello! | last event: done
The offset-versus-cursor demo above is also runnable as-is (it uses only sqlite3 from the standard library). To test real streaming against a server you would run one locally with a framework, which is not shown here because it needs a third-party package.
Q: REST, gRPC or GraphQL for a new API?
A: I decide by caller and traffic shape. A public or partner API defaults to REST because it is universally supported, debuggable with curl, and cacheable by CDNs; internal service-to-service calls with strict latency budgets or streaming get gRPC for typed stubs, deadlines and compact protobuf; a UI that stitches many entities on one screen may justify GraphQL, but I then own query-cost limits, N+1 batching and weaker HTTP caching. I usually end up with gRPC inside and REST at the edge rather than one style everywhere.
Q: Offset or cursor pagination, and why?
A: For anything live or large I use cursor (keyset) pagination: it is stable while rows are inserted and deleted, and the database seeks an index instead of scanning and discarding N rows, so page 10,000 costs about the same as page 1. The cost is that clients cannot jump to an arbitrary page and a total count needs a separate query. Offset remains reasonable for small static lists such as an admin table, where random page access matters more than stability.
Q: What does idempotent mean, and which HTTP methods are?
A: An operation is idempotent if performing it once or many times leaves the server in the same state; by HTTP semantics GET, HEAD, PUT, DELETE and OPTIONS are, while POST is not and PATCH is not guaranteed. That is a contract the server must actually honour, and it says nothing about responses being identical (a second DELETE may return 404). To make a POST retry-safe you add an idempotency key with server-side deduplication.
Q: How would you design the API for an LLM generation that takes up to 60 seconds?
A: If a person is watching, stream tokens over SSE so time to first token is short and the connection is doing visible work; set gateway and proxy timeouts to cover the worst case and disable proxy buffering for that route. If the caller is a batch job or the work can exceed the gateway timeout, return 202 with a job id, let the client poll with Retry-After or register a signed webhook, and support cancel. In both cases require an idempotency key so a retry does not pay for the generation twice.
Q: SSE or WebSockets for streaming model output?
A: SSE, in the common case, because generation is one-directional server-to-client, it is plain HTTP that passes through proxies, load balancers and auth middleware, and it has framing and reconnect semantics built in. WebSockets are worth their extra operational weight (stateful connections, harder load balancing and auth) when the client also streams continuously, for example live audio. SSE's costs are text-only framing and, in browsers, EventSource limited to GET without custom headers, so POST-based APIs read the stream with fetch.
Q: How do you version an API, and what counts as a breaking change?
A: I keep one version as long as possible by making only additive changes (new optional fields, new endpoints) and requiring clients to ignore unknown fields. Removing or renaming a field, changing a type or unit, tightening validation, or changing a default is breaking and needs a new version or a long deprecation with Deprecation/Sunset headers and usage telemetry showing who still calls the old path. Each supported major version is a maintenance cost, so I prefer fewer versions and longer additive evolution.
Q: How should errors be modelled?
A: Use the HTTP status for the class (401 vs 403, 404, 409, 422, 429, 5xx) and a JSON body with a stable machine-readable code, a human message, the offending parameter if any, and a request id that ties to logs and traces. Make retryability explicit: 429 and 5xx retry with exponential backoff and jitter, most other 4xx do not, because the same request will fail again and blind retries amplify incidents. Never require clients to match on message text.
Q: The client disconnects halfway through a streamed completion. What should the server do?
A: Detect the closed connection and cancel generation so the GPU frees its batch slot and KV cache instead of decoding tokens nobody reads, which matters when a single stream can hold accelerator memory for tens of seconds. Decide and document billing for the partial output (generally you have already spent the compute, so many providers count tokens produced), and log usage. The trap is that some proxies keep the upstream connection open after the client leaves, so cancellation has to be verified end to end.
Follow-up: Two retries with the same idempotency key arrive at the same time. What happens?
A: Both must not execute. The server inserts the key row with state in_progress using a unique constraint or an atomic set-if-absent; the loser sees the existing row and either returns 409 (the client retries shortly) or waits for the first to finish and replays its result. You also need an expiry on the key and a way to recover if the first worker crashed while in_progress, typically a lease timeout, and downstream side effects still need to be idempotent on their own.
Follow-up: You already sent a 200 and started streaming, then the model server fails. How do you report it?
A: You cannot change the status code, so the failure must be in-band: emit an error event with a code and request id, then close the stream, and make sure clients treat a stream that ends without a terminal done event as incomplete. Validate what you can before the first byte (auth, quota, input size, model availability) so most failures still surface as a proper 4xx or 503. For resumability, event ids let a client reconnect with Last-Event-ID, but replaying generation requires you to have buffered or persisted the tokens, which is extra state you must justify.
Follow-up: How do you paginate search results ordered by relevance score?
A: Scores are computed per query and can shift as the index updates, so a plain keyset on score is not stable. Options are a keyset on (score, id) if scores are deterministic for the request, a server-side cursor or snapshot with a short TTL for a consistent result set, or search_after-style token support in the search engine. Because deep pages of ranked results are rarely useful and expensive, many APIs cap the total depth and document that results can drift between pages.
In most system designs the API section is where you show you have thought about failure and growth: name the style per boundary (REST at the public edge, gRPC between services), give every list endpoint cursor pagination with a maximum page size, and give every money-spending POST an idempotency key with a stated retention window. For an LLM-serving API also state the latency model: with generation of a few hundred tokens taking seconds, use streaming for interactive traffic and 202 plus polling or webhooks for batch, and size concurrency with Little's law. As an illustrative estimate, assume 1,000 requests/s at peak, each streaming for 10 s on average: that is about 10,000 concurrent open connections, which drives the choice of an event-loop or async gateway, connection limits, and idle timeouts, and it means the gateway rather than the model server often enforces per-tenant quotas in requests and tokens. Note the API versioning and model-pinning policy, the deprecation process, the error and rate-limit headers, and how request ids flow into distributed tracing. Mention that queue-backed job endpoints are at-least-once, so consumers and webhooks must deduplicate by id.
Every API decision spends flexibility to buy predictability. REST buys cacheability and universal tooling and pays with chatty aggregation and awkward non-CRUD actions; GraphQL and gRPC fix different halves of that and move complexity to the server (query cost control) or to the client toolchain (protobuf, grpc-web). Cursor pagination buys stability and constant-time deep pages and pays with lost random access and no cheap total. Idempotency keys buy safe retries and pay with storage, atomicity requirements and retention policy, and they only cover the API boundary, not side effects beyond it. Async jobs and webhooks buy robustness for long work and pay with a state machine, at-least-once delivery, and clients that must now handle polling or callbacks; streaming buys perceived speed and pays with proxy configuration, in-band errors and cancellation handling. Versioning buys freedom to break things and pays with permanent maintenance of old versions. The wrong choice is usually over-engineering: GraphQL for a three-endpoint internal tool, webhooks for a job that finishes in 200 ms, or cursors for a 50-row admin list. Match the mechanism to the actual caller, latency and failure profile, and revisit it when those change.