An API (application programming interface) is a contract between two pieces of software. It lists the operations one side offers, what each one accepts and returns, how it fails, and what it promises about behaviour. The caller writes code against the contract, never against the code behind it, so either side can change as long as the contract holds. In system design, “the API” usually means a network API: the HTTP or gRPC calls that apps, partners and services make to each other.
Picture it first: the wall socket
Plug a phone charger into a wall socket and it works. You don’t know whether tonight’s power comes from a coal plant, a solar farm or a dam, and you don’t need to. The socket is the only thing you depend on.
Look closely and the socket makes several kinds of promise:
- A shape. Pins of a fixed size and spacing. Plugs built to that shape fit; others don’t.
- Behaviour. A fixed voltage and frequency, so the charger knows what it will get.
- Limits. A fuse rating. Draw too much current and the fuse trips, protecting the grid from you.
- Identity. The meter knows whose account the power is billed to.
Behind the wall, the utility can rebuild the grid and swap suppliers, and nothing in your house notices. But if it changed the socket’s shape overnight, every appliance in the country would stop working. That is why socket standards almost never change, and why travel adapters exist.
Map it back: the socket is the API, the grid is the implementation (code, databases, servers), your appliances are clients, the fuse is a rate limit, the meter is authentication, and a travel adapter is an old API version kept running for callers who can’t upgrade yet.
An API is a contract
The key word is interface. An API separates two things:
- Interface: what the caller can see and rely on. Operation names, inputs, outputs, errors and documented behaviour.
- Implementation: everything that makes it work. The code, the language, the database, caches, how many servers run it, which other services it calls.
Because the caller depends only on the interface, the provider can rewrite the implementation (move from MySQL to PostgreSQL, split a monolith, add a cache) without asking anyone, and the caller can be rewritten in any language without telling the provider. Two teams, or two companies, can move at their own speed.
APIs exist at every layer of a computer:
- Library and SDK APIs: function signatures inside one process. Python’s
json.loads(text)promises to turn a JSON string into objects or raise an error. - Operating system APIs: system calls. POSIX
read(fd, buf, count)reads up tocountbytes from a file, pipe or socket, and your program never learns which driver did the work. - Browser (Web platform) APIs:
fetch(), geolocation and local storage, implemented differently by each browser but identical to your JavaScript. - Network APIs: operations one process offers another across a network, usually over HTTP or gRPC. This is what a system design interview means by “the API”, and the focus of this lesson.
Network APIs also differ by audience:
- Public APIs are open to any developer who signs up, such as a payment gateway or a maps API.
- Partner APIs serve a known set of businesses under an agreement: restaurants pushing menus to a delivery platform, or a bank connecting to a UPI switch.
- Internal APIs connect your own services, such as the order service calling the pricing service. One user request often fans out into many of them, and nobody outside ever sees them.
API vs protocol
A protocol is a general set of rules for a conversation: HTTP, TCP, AMQP. An API is the specific set of operations one system offers, usually carried over a protocol. HTTP says what POST and 404 mean in general; a payments API says that POST /v1/payments asks a payer for money, and what a 404 from it implies.
Anatomy of a web API call
Take a UPI-style payments API that a shopping app calls at checkout. This request asks a customer to pay ₹249:
POST /v1/payments HTTP/1.1
Host: api.payquick.example
Authorization: Bearer <access-token>
Content-Type: application/json
Idempotency-Key: 5f0c1e9a-2b7d-4c41-9a3e-7d2b8c6f1a04
{"order_ref": "ORD-88213", "amount_paise": 24900, "currency": "INR", "payer_vpa": "asha@okbank"}
And the server’s answer:
HTTP/1.1 201 Created
Location: /v1/payments/pay_9f3k2
Content-Type: application/json
{"id": "pay_9f3k2", "status": "pending", "amount_paise": 24900, "currency": "INR"}
Read it part by part:
- Base URL and version.
https://api.payquick.example/v1names the API and its major version. - Method.
POSTcreates or acts;GETreads,PUTreplaces,PATCHupdates part,DELETEremoves. Each makes promises about safety and retries (see HTTP and HTTPS). - Path.
/paymentsis a collection;/payments/pay_9f3k2is one payment in it. - Query parameters filter or page a read:
GET /v1/payments?status=pending&limit=20(see pagination). - Headers carry metadata: who is calling (
Authorization), how to parse the body (Content-Type), and a retry token (Idempotency-Key) so a retried POST can’t charge twice (see idempotency). - Body. The input, in an agreed format: JSON here, Protocol Buffers in gRPC.
- Status code.
201 Created: it worked and a new resource exists. - Response headers.
Locationsays where the new payment lives. - Response body. The payment as the server now sees it. Notice
"status": "pending": no money has moved, because the payer still has to approve in their UPI app. The client pollsGET /v1/payments/pay_9f3k2or waits for a webhook.
Status classes do most of the work
RFC 9110 gives the first digit of a status code its meaning, and says a client must understand the class even when it doesn’t know the exact code: an unknown 471 is handled like a 400. That rule lets generic code (retry libraries, proxies, SDKs) behave sensibly:
| Class | Meaning | What a well-behaved client does |
|---|---|---|
| 2xx | Success | Use the body; 201 means created, 202 means accepted for later |
| 3xx | Look elsewhere | Follow Location, or reuse the cached copy on a 304 |
| 4xx | The request is wrong | Fix it before sending again; only 408 and 429 invite a retry, after waiting |
| 5xx | The server failed | Retry with backoff, but only if the operation is idempotent |
The individual codes worth knowing are listed in HTTP and HTTPS.
Errors are data too
A status code tells you what class of thing went wrong, not what to do about it. RFC 9457 defines a standard error body, application/problem+json, so every endpoint fails the same way:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.payquick.example/problems/unknown-vpa",
"title": "The payer's UPI ID was not found.",
"status": 422,
"detail": "asha@okbnk does not match any bank handle.",
"instance": "/v1/payments/requests/req_51c7"
}
type is a stable URI that client code can branch on; title and detail are for humans; instance names this occurrence for a support ticket. The type is part of the contract. The wording of detail is not, though someone will parse it anyway.
More than request shapes
A JSON example shows the shape of a call. The contract callers really depend on is far bigger, and most bugs between teams live in the parts nobody wrote down.
- Shapes: operations, field names and types, what is required, which error codes exist. This is the part an OpenAPI file captures well.
- Semantics: what an operation does. Does
POST /paymentsmove money, or only ask the payer to approve? Isamount_paisein paise or rupees? Two systems can agree on every field and still disagree on meaning. - Guarantees: a retry with the same
Idempotency-Keyreturns the first result instead of charging again; events for one payment arrive in order, or don’t; aGETright after a201sees the new payment, or may briefly 404 because reads hit a lagging replica. Callers code around these, so state them. - Limits: requests per second per key, maximum page size, maximum body size, how long results are kept. A caller who doesn’t know the limits discovers them in production as 429s and 413s.
- Auth model: how a caller proves who it is and what it may touch. An API key identifies a calling application: simple, but a long-lived secret that usually carries the account’s full power. An OAuth 2.0 access token, sent as
Authorization: Bearer …, is issued by an authorization server, expires quickly and carries scopes, so a user can let an app read their orders without letting it place them. See OAuth 2.0 and OpenID Connect. - Service level and lifecycle: expected latency and availability, support hours, how versions are named, and how much notice you get before something is removed.
Everything outside the boundary is free to change, and that freedom is the payoff. Before a Flipkart-style sale, a team can add a cache, shard a database or rewrite a service in a week, and if the contract holds, not one caller notices.
Write down the invisible parts
When you sketch an API in an interview, add one line per endpoint for the promises that aren’t shapes: “idempotent with a key, kept 24 hours”, “list may lag writes by a couple of seconds”, “100 requests/s per merchant”. It shows you know where integrations actually break.
API styles at a glance
Every network API follows one of a few interaction shapes, and the shape matters more than the wire format. Who starts the conversation, and does anyone wait?
| Style | How it talks | Contract written in | Reach for it when | What you pay |
|---|---|---|---|---|
| REST (resource-oriented HTTP + JSON) | Request–response on URLs; HTTP methods as verbs | OpenAPI | Public and partner APIs; cacheable reads; curl must work |
Several calls per screen; fetching too much or too little |
| RPC (gRPC) | Named methods, binary Protobuf over HTTP/2; streams too | .proto files |
Internal calls at high rates; strict types; deadlines | Browsers need a proxy; harder to debug by eye |
| GraphQL | The client queries one endpoint, naming the fields it wants | GraphQL schema (SDL) | Many screens and clients wanting different slices of the same data | HTTP caching is hard; queries need cost limits |
| Events on a broker | A producer publishes to a topic; consumers read when ready | AsyncAPI, a schema registry | Fan-out; decoupling teams; absorbing spikes | Eventual consistency; duplicates and ordering |
| Webhooks | A provider POSTs an event to a URL the customer registered | OpenAPI webhooks, an event catalogue |
Telling another company that something happened | Retries, signatures, out-of-order delivery |
| Real-time connections | A long-lived WebSocket or SSE stream; the server pushes | A message schema per channel | Live tracking, chat, scores | Millions of open connections to run |
A Swiggy-style delivery platform uses most of these at once: REST for restaurant partners, a backend-for-frontend or GraphQL for the apps, gRPC inside, topics for order events, webhooks to partners, and a WebSocket for the rider’s live position. The first three are compared in REST vs GraphQL vs gRPC; push styles are in real-time connections and webhooks; and the wait-or-don’t-wait choice is in synchronous vs asynchronous communication.
Writing the contract down
A contract that lives only in a wiki drifts from the code within weeks. Machine-readable descriptions fix that:
- OpenAPI for HTTP APIs: paths, parameters, bodies, responses and security schemes, in YAML or JSON. The current line is 3.2 (3.2.0 shipped in September 2025, patch 3.2.1 in September 2026), per the specification.
- Protocol Buffers for gRPC: messages and
servicedefinitions in.protofiles. - GraphQL SDL: the schema is the contract, and clients can query it.
- AsyncAPI for event-driven APIs: channels, message payloads and who publishes what.
Here is the payment lookup in OpenAPI:
openapi: 3.2.0
info: { title: PayQuick Payments, version: "1.4.0" }
paths:
/v1/payments/{id}:
get:
operationId: getPayment
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
description: The payment
content:
application/json:
schema: { $ref: "#/components/schemas/Payment" }
"404":
description: No payment with this id for this merchant
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
And the same operation as a gRPC service:
service Payments {
rpc GetPayment(GetPaymentRequest) returns (Payment);
}
message GetPaymentRequest { string id = 1; }
message Payment {
string id = 1;
int64 amount_paise = 2;
string status = 3; // pending, succeeded, failed
}
One file like this feeds every consumer of the contract.
- Code generation: server stubs that reject malformed requests before your code runs, and client SDKs that give callers typed methods instead of hand-built URLs.
- Docs and mocks: reference docs, plus a mock server so the app team can build against the API before the backend exists.
- Contract tests: in consumer-driven contract testing, each consumer publishes the requests it sends and the fields it reads, and the provider’s CI replays them against every build, so a provider learns it broke the Android app before release.
- Breaking-change checks: CI diffs the new spec against the last released one and fails the build on a removed field or a new required parameter.
This is API-first (or design-first) development: agree the contract, publish it, then build both sides in parallel. You gain parallel work and fewer integration surprises. You pay with design time up front and the discipline to keep the spec, not the code, as the source of truth.
Why contracts are hard to change
Once an API has callers, any behaviour they can observe is at risk of becoming part of the contract. Hyrum Wright, then a software engineer at Google, described it in what is now called Hyrum’s Law:
“With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.”
It happens quietly. A caller skips sorting because your list happened to come back newest first. A reconciliation script pattern-matches the detail text of an error. A partner stores your IDs in a 12-character column because every ID so far was 12 characters. A mobile app ships a 2-second timeout that was fine while your p99 was 400 ms. None of it was promised; all of it breaks when you change it. Wright calls this the implicit interface, and with enough users it matches the implementation exactly.
Google’s API guidelines (AIP-180) split compatibility three ways. Source: old code still compiles against the new SDK. Wire: old clients still exchange bytes correctly with the new server. Semantic: old clients still get what a reasonable developer would expect. A safe change passes all three.
| Change | Safe? | Why |
|---|---|---|
| Add a new endpoint or method | Safe | Old callers never call it |
| Add an optional field to a response | Safe, if clients ignore unknown fields | Old clients skip it; new clients use it |
| Add an optional request field whose default keeps the old behaviour | Safe | Old requests behave exactly as before |
Add a value to a response enum (refunded) |
Risky | An old switch statement may crash or mishandle it; document that enums grow |
| Add a required request field | Breaking | Every old request is now invalid |
| Remove or rename a field | Breaking | To an old client, a rename is a removal |
Change a type or format ("249.00" to 24900, IPv4 to IPv6) |
Breaking | Parsers and database columns downstream reject it |
| Change meaning or units (rupees to paise) | Breaking, and silent | Nothing errors; amounts are 100× wrong |
| Tighten validation or lower a limit | Breaking | Requests that passed yesterday fail today |
| Change a default value | Breaking | Callers who relied on the default get new behaviour |
| Paginate a list that used to return everything | Breaking | Old clients think the first page is the whole list |
Two habits make most changes safe. Providers only add: new optional fields, new endpoints, new enum values announced in advance. Consumers are tolerant readers: they ignore unknown fields, don’t depend on undocumented ordering, and handle enum values they’ve never seen. Protobuf helps, because it identifies fields by number on the wire and skips numbers it doesn’t recognise, which is also why a deleted field’s number must never be reused. More in serialization and schema evolution.
Versioning and deprecation
When a breaking change is truly needed, give callers a way to opt in and time to move.
| Strategy | Example | You gain | You pay |
|---|---|---|---|
| Major version in the URL | /v1/payments, later /v2/payments |
Visible in logs, docs and gateway routes; old and new run side by side | Big-bang migrations; two code paths or a translation layer to maintain |
| Version in a header, often a date | Stripe-Version: 2026-09-30.endive |
Small, frequent breaking changes; each customer upgrades when ready | The server must translate between many versions; invisible in URLs |
| No versions, evolve only | Deprecated GraphQL fields; internal protos | No migrations for callers; one code path | Old fields pile up; only works if you never need a true break |
Google’s AIP-185 puts a major version in the URL path and the protobuf package, and only a major number: v1, never v1.1, because compatible changes ship in place. Old and new majors must work side by side for a transition period, and beta features should stay deprecated for 180 days before removal. Newer Google APIs can also pin a dated version with an X-Goog-Api-Version header.
Stripe gives each account a default API version and lets a single request override it with the Stripe-Version header. Since the 2024-09-30.acacia release it publishes monthly versions with no breaking changes, plus a major release twice a year that may break things.
Internal APIs rarely need any of this. You own every caller, so instead of shipping /v2 you add the new field, migrate callers, watch traffic on the old field fall to zero, and then delete it. That is the same expand-and-contract pattern used in online schema migrations.
Retiring something public takes five steps:
- Announce it in the changelog and by email, with dates and a migration guide.
- Signal it in every response. RFC 9745 (March 2025) defines
Deprecation: @1798761599, a Unix timestamp (here 31 December 2026, 23:59:59 UTC) for when the resource is or was deprecated, and aLinkwithrel="deprecation"pointing at the docs. RFC 8594 addsSunset: Wed, 30 Jun 2027 23:59:59 GMT, the time it is expected to stop responding, which must not be earlier than the deprecation date. Both are hints: a deprecated resource keeps working until its sunset. - Measure who still calls the old version, per API key, and contact them directly.
- Brown out: switch the old version off for short, announced windows so forgotten integrations fail loudly while there is still time.
- Remove it on the sunset date, returning a clear error such as
410 Gone.
Going deeper: a network call is not a function call
RPC frameworks make a remote call look like a local one. It isn’t, and a good API design absorbs the difference.
Latency. A local function call returns in nanoseconds. A network call pays a round trip first: for the examples here, assume about 1 ms between services in one data centre and 80 ms from a phone on a mobile network (see numbers to know).
Unknown outcomes. A function either returns or throws. A network call can also time out with no answer, and the payment may have gone through just as the connection dropped. The contract must say what is safe to retry, which is why write APIs take idempotency keys and clients use timeouts, retries and backoff with jitter.
Chatty vs chunky. An order-tracking screen needs ten pieces of data (order, restaurant, rider, ETA and so on), and each request needs an ID from the one before.
From the phone, one call at a time: 10 × 80 ms = 800 ms of waiting on round trips alone. Through one screen-shaped endpoint that makes the same calls server-side: 80 ms + 10 × 1 ms = 90 ms, about 9× faster, with fewer radio wake-ups on the phone. You pay with a bigger endpoint coupled to one screen, and more data than some clients need. That is why teams build a backend-for-frontend per app or use GraphQL; see API composition, BFF and GraphQL federation and the chatty I/O anti-pattern.
Internal vs external APIs
| Internal (service to service) | External (public or partner) | |
|---|---|---|
| Trust | Authenticated service identity, often mTLS; still check permissions | Assume hostile: validate everything, limit every key |
| Compatibility horizon | Weeks: you can find and fix every caller | Years: some callers never upgrade |
| Typical style | gRPC with Protobuf, or events | REST with JSON and OpenAPI, plus webhooks |
| How it changes | Expand, migrate callers, contract | Versions, changelog, deprecation headers, sunset dates |
Gateways and API management
Public APIs usually sit behind an API gateway that checks keys or tokens, applies per-key quotas and rate limits, and records usage per caller. Those per-key analytics are what make deprecation possible: you can’t retire v1 without knowing who still calls it.
The API is a product
For external developers, the API is the product. Good ones ship docs generated from the spec plus task guides, a sandbox with test credentials (test UPI IDs that always succeed or always fail), SDKs, a changelog, a status page, and error messages that say how to fix the problem. A useful single measure is time to a new developer’s first successful call.
Security: authorization per object
The OWASP API Security Top 10 (2023) ranks Broken Object Level Authorization (BOLA) first: an endpoint takes an object ID from the caller and never checks that this caller may see that object, so any valid key that requests GET /v1/payments/pay_9f3k2 gets another merchant’s payment. Others map straight onto contract layers: property-level authorization (API3), such as accepting "status": "succeeded" in a create request; unrestricted resource consumption (API4), meaning missing limits on page sizes, uploads or OTP sends; and improper inventory management (API9), such as a forgotten v1 still running without the new checks.
Authentication is not authorization
A gateway can prove who is calling. Only the service that loads the payment can check that this caller owns this payment, so make that check on every request that takes an ID, in the service, against the owner stored with the object.
Cheat sheet
| Idea | Remember it as |
|---|---|
| API | A contract: operations, inputs, outputs, errors and guarantees, hiding the implementation |
| A web call | Method + path + query + headers + body → status + headers + body |
| Status classes | 2xx done, 3xx look elsewhere, 4xx fix the request, 5xx retry only if idempotent |
| Errors | application/problem+json: type, title, status, detail, instance (RFC 9457) |
| Beyond shapes | Semantics, guarantees, limits, auth, service level, lifecycle |
| Styles | REST at the edge, gRPC inside, GraphQL for many screens, events to decouple, webhooks to notify, WebSockets or SSE for live data |
| Describe it | OpenAPI, .proto, GraphQL SDL, AsyncAPI → codegen, docs, mocks, contract tests |
| Hyrum’s Law | Every observable behaviour will be depended on by somebody |
| Safe changes | Add optional fields and endpoints; clients ignore what they don’t know |
| Breaking changes | Remove, rename, retype, change meaning or defaults, tighten validation, add a required input |
| Versioning | /v1 in the path, or a dated header (Stripe); internally, expand and contract |
| Retiring | Deprecation (RFC 9745) + Sunset (RFC 8594) + per-key usage + brownouts |
| Network ≠ function | Round trips, unknown outcomes; 10 × 80 ms = 800 ms vs one 90 ms call |
| Top API risk | BOLA: check the caller may access this object, on every request |
How to say it in the interview
“An API is a contract: the operations a system offers, their inputs and outputs, its errors, and promises like idempotency, limits and latency. It hides the implementation, so I can change the database or split the service without breaking anyone. Here I’d expose REST with JSON to apps and partners, described in OpenAPI, and use gRPC between internal services. Writes take an idempotency key, errors use problem details, and lists are cursor-paginated. I’d put
/v1in the path, make only additive changes inside it, and retire anything with Deprecation and Sunset headers, because by Hyrum’s Law, once clients depend on behaviour, they depend on all of it.”
What makes a change breaking? Anything an unmodified existing client can observe going wrong: removing, renaming or retyping a field, adding a required input, changing meaning, units or defaults, or tightening validation. Adding optional fields and new endpoints is safe as long as clients ignore what they don’t recognise.
How would you version this API? For public callers, a major version in the path with only additive changes inside it, and a new major only for a true break, run side by side and retired with deprecation headers and per-key tracking. For internal APIs, no versions at all: expand, migrate the callers, then contract.
Why not just expose the database? Then your schema becomes the contract. You could never rename a column, shard, add a cache or change engines without breaking callers, and you couldn’t enforce business rules, limits or per-object authorization in one place. The API is the seam that lets the inside change.
What’s the difference between an API and a protocol? A protocol such as HTTP or TCP defines how any two parties talk. An API is one system’s specific operations and promises, carried over a protocol.
How do internal APIs differ from public ones? You control every internal caller, so you can change contracts in weeks with expand-and-contract, favour gRPC, and trust authenticated service identities. Public callers are unknown, untrusted and slow to upgrade, so they need explicit versions, strict validation, per-key limits and long deprecation windows.
Where to go next
- REST vs GraphQL vs gRPC: the three request–response styles in depth.
- API design for system design interviews: from requirements to endpoints on the whiteboard.
- HTTP and HTTPS: methods, status codes and connection costs.
- Serialization and schema evolution: changing JSON and Protobuf schemas safely.
- Idempotency: making retries safe.
- API gateway and rate limiting: the front door that enforces keys and limits.
- Pagination: offset vs cursor: returning big lists in pieces.
- Webhooks and real-time connections: pushing data to clients.
- API composition: aggregation, BFF and GraphQL federation: chunky, screen-shaped APIs.
- HTTP API implementation gotchas: traps that bite after the design is done.
References and further reading
- RFC 9110 — HTTP Semanticsrfc-editor.orgStatus code classes, and the rule that a client must treat an unknown code as the x00 code of its class.
- RFC 9457 — Problem Details for HTTP APIsrfc-editor.orgThe application/problem+json error body with type, title, status, detail and instance; replaces RFC 7807.
- OpenAPI Specification v3.2.1spec.openapis.orgThe language-agnostic description format for HTTP APIs; its revision history dates 3.2.0 to September 2025 and 3.2.1 to September 2026.
- Hyrum’s Law (Hyrum Wright)hyrumslaw.comThe original wording of the law and the idea of an implicit interface that grows with the number of users.
- Google AIP-180 — Backwards compatibilitygoogle.aip.devSource, wire and semantic compatibility, and a catalogue of changes that break callers: renames, type changes, new required fields, changed defaults.
- Google AIP-185 — API versioninggoogle.aip.devMajor version in the URL path (v1, never v1.1), side-by-side versions during migration, a recommended 180-day beta deprecation, and date-based versions in a header.
- Stripe API reference — Versioningdocs.stripe.comAccount-pinned API versions, the Stripe-Version override header, and monthly non-breaking releases plus twice-yearly major releases since 2024-09-30.acacia.
- RFC 9745 — The Deprecation HTTP Response Header Fieldrfc-editor.orgThe 2025 Deprecation header (a structured-field date), the deprecation link relation, and its relationship to Sunset.
- RFC 8594 — The Sunset HTTP Header Fieldrfc-editor.orgAn HTTP-date after which a resource is expected to stop responding, treated by clients as a hint.
- OWASP API Security Top 10 — 2023owasp.orgThe ten most common API risks, led by Broken Object Level Authorization (API1:2023).