See It Flow

Core Concepts

API Design

An API is a contract between clients and services. Good design makes systems easier to evolve, safer to retry, and easier to debug.

Mangesh MandlikLast updated:

What Is API Design?

A contract between software that changes on different schedules

API design is the work of defining a reliable contract between a service and its consumers: which capabilities exist, what each operation means, which inputs it accepts, which guarantees it gives, how errors are represented, who may call it, and how all of that can change without breaking the people already depending on it. REST, GraphQL and gRPC are different ways of expressing those same decisions, not substitutes for making them.

The contract deliberately excludes tables, queues and deployment topology. The checkout team can reshape its schema whenever it likes, but it cannot casually turn POST /orders from returning an order identifier into returning an unrelated nested object. The reason is that the two sides do not deploy together. A web client ships with the server, an internal fulfilment system follows its own release train, a mobile build installed last quarter keeps sending last quarter's requests, and an external merchant may not touch an integration for a year. The API is the only thing all of them share, so it is where compatibility has to be designed rather than hoped for.

That changes what the job is. Version 1 working is the easy part. Every field you add to a response becomes load-bearing the moment a client reads it, and every field you remove is a potential incident for whoever still does. Design as if you cannot coordinate a deploy with your callers, because for external or widely consumed APIs you often cannot. The question to keep asking is not "does it work today?" but "can I change it tomorrow?", and the rest of this lesson follows one order service through the decisions that make the answer yes.

API Design Best Practices

The decisions this lesson walks through, as a short checklist

API design best practices come down to deciding the contract before the code: what each operation means, who may call it, how it fails and how it can change without breaking existing callers.

  • Define what success promises for each operation, and expose asynchronous status instead of a generic 200 OK.
  • Scope every object-level read and write to the caller in the data-access layer (authorization).
  • Document validation rules, including whether you use 400 or 422, and return problem documents with stable codes.
  • Set a default and maximum page size, and allow-list sort and filter fields (collections).
  • Make unsafe retries explicit with an Idempotency-Key or a status lookup (timeouts and retries).
  • Prefer additive change, treat response enums as open, and publish a deprecation policy (evolution).

API Design Patterns

Recurring shapes that keep a contract stable

Several API design patterns recur in this lesson: each one gives a common problem a named, predictable shape that clients can learn once.

  • Actions as sub-resources. POST /v1/orders/ord_8f32/cancellations gives a business transition its own identifier, status and audit trail (resource modeling).
  • Idempotency key. A client-supplied key lets the server recognize a repeated POST and return the original result.
  • Problem document. Errors use application/problem+json with a stable code and a request ID.
  • Bounded collections. Every list has a default and maximum page size, and the choice between offset and cursor pagination is made deliberately.
  • Additive evolution and versioning. New optional fields ship without a version bump, and breaking changes get a new version that coexists with the old one.

Visual Story

Resource modeling, validation, errors, and versioning — watch the full flow

API Design — Complete Visual Deep-Dive

API Design — Complete Visual Deep-Dive
Resource Modeling
0 / 0
✅Up nextValid Request Flow

⤢ Expand for synchronized narration timeline

Begin With the Consumer's Workflow

Capabilities and guarantees first, protocol second

Before choosing a style, write down what the consumer must be able to do and rely on. For an order service that is creating an order, reading its current state, listing a customer's orders, cancelling an eligible order, and retrying creation safely after a network failure. Each of those has its own authorization, consistency, latency and retry requirements. Writing them down first turns the later choice between REST and gRPC into a consequence of the requirements rather than a fashion statement.

Then define what each operation means. "Create an order" might mean the service has durably accepted an order record, not that payment and shipping have succeeded. If processing continues asynchronously, the response must expose a status that represents that reality, because a generic 200 OK forces the client to guess whether the work is done. The rest of the design hangs off that meaning: the lifecycle, the validation rules, the error vocabulary and the retry behavior.

Model Resources, Identifiers and Actions

Nouns for the common case, deliberate verbs for real business transitions

Resource-oriented HTTP APIs normally put nouns in paths and let the method carry the operation:

Order service routes
POST   /v1/orders                        create
GET    /v1/orders/ord_8f32               read one
GET    /v1/customers/cus_12/orders?limit=20   list, scoped to a customer
PATCH  /v1/orders/ord_8f32               partial update

Compare that with POST /createOrder, GET /getOrderById, POST /updateOrderand POST /deleteOrder. The action-in-the-URL style is not inherently broken, and for operations that are not naturally CRUD it is sometimes the honest choice. Its cost is that every verb becomes a new route that proxies, caches, permission models and client developers cannot reason about generically. A consistent resource model gives you a collection (/orders: list, filter, create) and members (/orders/ord_8f32: read, replace, patch), and lets a client that learned one resource guess the shape of the next.

A resource map for the order serviceThe collection /v1/orders supports creating and listing orders. A single order lives at /v1/orders/{id} and owns an items sub-resource. A business transition such as cancellation is modeled as a cancellations noun under the order. A customer-scoped list reads orders through the customer, and deeply nested hierarchies are flattened once the child has a stable identity of its own./v1/orderscollection: POST create, GET list/v1/orders/{id}member: GET read, PATCH update…/{id}/itemsowned sub-resource…/{id}/cancellationsdomain action as a noun/v1/customers/{id}/orders?limit=20 scoped list/v1/tasks/{id}flatten deep nestingone item
A resource map for the order service. The collection /v1/orders supports creating and listing orders. A single order lives at /v1/orders/{id} and owns an items sub-resource. A business transition such as cancellation is modeled as a cancellations noun under the order. A customer-scoped list reads orders through the customer, and deeply nested hierarchies are flattened once the child has a stable identity of its own.

Sub-resources should express real ownership: /orders/ord_8f32/items means the items belonging to that order. "Never nest more than two levels" is a heuristic, not a rule, but the problem it points at is real. A path like /orgs/1/teams/2/projects/3/tasks/4 couples every URL to an internal hierarchy and makes each ancestor ID part of every deeper address. Once a child has a stable identity of its own, flatten it to /tasks/4 and keep project_id as a field or a filter.

Give the lifecycle a shape

Most resources that matter have a lifecycle. An order is created as PENDING_PAYMENT, becomes CONFIRMED when payment is captured, and ends as FULFILLED or CANCELLED. The transitions carry rules: an unpaid order can be cancelled freely, a confirmed order can be cancelled until it ships, and a fulfilled order cannot be cancelled through the same operation at all. Writing the states and legal transitions down gives you, in one step, the validation table for the API, the vocabulary for error codes and the documentation.

Order lifecycle and the transitions the API allowsAn order starts as PENDING_PAYMENT and moves to CONFIRMED when payment is captured, then to FULFILLED when shipped. A cancellation request is valid from PENDING_PAYMENT or CONFIRMED and leads to CANCELLED. The same request against a FULFILLED order is rejected with a 409 conflict instead of silently changing state.PENDING_PAYMENTcreated, unpaidCONFIRMEDpayment capturedFULFILLEDshipped, terminalCANCELLEDterminal409 ORDER_STATE_CONFLICTcancel after fulfilmentpayment capturedshippedPOST …/cancellationsPOST …/cancellations
Order lifecycle and the transitions the API allows. An order starts as PENDING_PAYMENT and moves to CONFIRMED when payment is captured, then to FULFILLED when shipped. A cancellation request is valid from PENDING_PAYMENT or CONFIRMED and leads to CANCELLED. The same request against a FULFILLED order is rejected with a 409 conflict instead of silently changing state.

This is why a generic field update is a poor vehicle for business transitions. If cancelling means PATCH /v1/orders/ord_8f32 with {"status":"CANCELLED"}, every client can in principle write any status, the server has to infer intent from a field diff, and refund or restock side effects hide behind an innocent-looking assignment. A domain action states the intent directly:

Cancellation as a sub-resource
POST /v1/orders/ord_8f32/cancellations
Content-Type: application/json

{ "reason": "customer_request" }

HTTP/1.1 201 Created
Location: /v1/orders/ord_8f32/cancellations/can_01
{ "id": "can_01", "order_id": "ord_8f32", "status": "REFUND_PENDING" }

Modeling the action as a noun gives it an identifier, an audit trail, its own status for asynchronous refunds, and a natural place for an idempotency key.

A verb endpoint such as POST /orders/ord_8f32/cancel is also legitimate. The noun form tends to age better when the action itself has state, such as a refund that completes later, because the client can read or list cancellations instead of inferring progress from the order's status. DELETE is usually wrong here: the order record must survive for accounting, so the business meaning is "cancel", not "remove". Pick one convention for domain actions and apply it everywhere.

Public identifiers deserve the same care. Use stable, opaque IDs such as ord_8f32 rather than a database sequence or a shard-encoded value. An identifier that embeds a shard becomes false the day a record migrates, and sequential integers invite enumeration and leak volume. Opaque IDs are not an authorization mechanism, which is the subject of a later section.

Design One Complete Request and Response

Walking POST /v1/orders, including the money and what the server owns

Naming only gets you the URL and method. The harder work is the shape of what is sent and what can be relied on afterwards. A successful synchronous creation looks like this: the client sends a customer, line items with SKUs and quantities, and a currency. It does not send prices or a total.

Request and response (illustrative values)
POST /v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 4d55e44b-9e62-4cb1-b9c3-9ad2f3d09c40
Authorization: Bearer <token>

{ "customer_id": "cus_12",
  "items": [{ "sku": "sku_42", "quantity": 2 }],
  "currency": "INR" }

HTTP/1.1 201 Created
Location: /v1/orders/ord_8f32
Content-Type: application/json

{ "id": "ord_8f32", "status": "PENDING_PAYMENT",
  "currency": "INR", "total_minor": 259800 }

If sku_42 costs 129900 minor units, two of them give 259800, which is INR 2,598.00. The server computed it.

Two rules sit behind that example. First, represent money as an integer count of the currency's minor unit plus a currency code, never as a floating-point number. Binary floats cannot represent most decimal fractions exactly, and a JSON consumer may parse a decimal string as a double. The exponent depends on the currency: INR and USD use two decimal places, JPY none, and some currencies such as KWD three (ISO 4217), so the contract should say "minor unit of the stated currency" rather than "paise" or "cents".

Second, the server computes authoritative totals from its own catalogue, discounts and tax rules. A client-submitted price is at best a display hint and at worst a way to buy for one unit. If the client displayed a price, let it send an expected total and have the server reject the order with a documented error when it differs; the client's number becomes a precondition, never the source of truth. The response also says something: 201 rather than 200 because a resource was created, Location telling the client where it lives, an identifier assigned by the server, and PENDING_PAYMENT making clear that creation is not payment.

A stable schema also fixes details that are usually left implicit: which fields are required, allowed enum values, maximum lengths and array sizes, and whether an omitted field means something different from an explicit null. Optional fields need defined defaults so old and new clients agree when a field is absent. That last point is what makes additive evolution possible: adding an optional gift_wrap request field or an estimated_delivery response field breaks nobody, provided clients ignore what they do not recognise.

Method Semantics and the Three Kinds of PATCH

Safe and idempotent are different questions, and PATCH is not one contract

RFC 9110 defines a method as safe when the client does not request, and is not expected to cause, a state change on the origin; the server may still write an access log. A method is idempotent when the intended effect of several identical requests is the same as one. Idempotent does not mean the response is identical, and it says nothing about side effects on other systems. The table states the conventional semantics a well-behaved API should respect, not any framework's defaults.

MethodSafeIdempotentWhat it means for the contract
GETyesyesRead-only by contract. Safe to retry, cache and prefetch.
POSTnonoCreates a resource or triggers processing. Retry safely only with an idempotency key or a status lookup.
PUTnoyesReplaces the target with the supplied representation, so repeating it lands on the same state.
PATCHnodependsIdempotent for target-state bodies, not for deltas such as "+1".
DELETEnoyesThe resource is gone after one call or ten, though the repeat may answer 404 instead of 204.

The architectural reasoning behind these constraints is in the REST lesson. The method that needs the most thought here is PATCH (RFC 5789), which only says "apply a partial modification". The body format defines the contract, and the method is idempotent only if that format makes it so. Three variants appear in practice, and an API must pick one on purpose.

  • Field replacement (JSON Merge Patch, RFC 7396). The body is a partial document, and fields present overwrite the stored ones. Its rule for null is a trap: a null member means "remove this field", so it cannot store a literal null, and arrays are replaced wholesale. Because it expresses target state, repeating the request lands in the same state.
  • JSON Patch (RFC 6902). The body is an ordered list of operations such as replace, add and test, addressed with JSON Pointers. It is precise and can guard on a current value, but operations such as appending to an array are not idempotent, and clients need a library to build them.
  • Domain-specific transition. The body is the intent, like the cancellation above. The server owns what the intent implies, which is why business transitions should not be modeled as updating a status field.

The retry consequence is concrete. A client sends PATCH {"quantity": "+1"}, the gateway times out, the client retries, and the quantity is doubled. A delta-style patch is unsafe to repeat; the target-state form {"quantity": 5} is safe. Whichever variant you pick, protect concurrent edits: return an ETag on reads and require If-Match on writes, so a stale edit fails with 412 Precondition Failed (or 428 when the header is missing) instead of silently overwriting another client's change.

The Request Lifecycle: Authenticate, Authorize, Validate, Commit

The order in which a server decides, and why each stage has its own status code

A well-built endpoint answers its questions in a deliberate order, and each stage maps to a different class of response. The server first establishes who is calling, then whether that caller may touch this specific object, then whether the input is acceptable, then whether the business rules permit the operation in the object's current state, and only then commits durably and responds.

Request lifecycle for POST /v1/ordersSix stages run left to right: authentication, resource authorization, validation, the business operation, a durable commit, and the response. Each stage before the response can stop the request, and each has its own failure status shown below it. The commit stage is the only one whose failure can leave the caller not knowing whether the work happened.1. Authenticatewho is calling?2. Authorizethis object, this action3. Validateshape, then rules4. Business opstate + invariants5. Durable committransaction succeeds6. Respond201 + Location401WWW-Authenticate403 / 404no access / hidden400 / 422problem + field errors409state conflict503 / timeoutoutcome may be unknown
Request lifecycle for POST /v1/orders. Six stages run left to right: authentication, resource authorization, validation, the business operation, a durable commit, and the response. Each stage before the response can stop the request, and each has its own failure status shown below it. The commit stage is the only one whose failure can leave the caller not knowing whether the work happened.

Authentication is not authorization

A valid token proves something about the caller's identity or delegated authority. It says nothing about whether that caller may read order ord_8f32. A route that checks only that the user is logged in is vulnerable to broken object-level authorization (BOLA, the first item in the OWASP API Security Top 10): any authenticated user can change the identifier in the path and read someone else's order. Opaque IDs make guessing harder but are not a defence, because IDs leak through logs, referrers and shared links.

Authorize at the data-access boundary
# authentication: who is calling (401 if absent or invalid)
principal = authenticate(request.headers["Authorization"])

# resource authorization: scope the query to the principal
order = orders.get(id=order_id, tenant_id=principal.tenant_id,
                   customer_id=principal.customer_id)
if order is None:
    raise NotFound()   # 404: also hides whether the id exists for someone else

require_scope(principal, "orders:read")

Putting ownership into the query itself means no code path can forget the check. Authorization header values must never be logged.

Status codes follow the stage. 401 means missing or invalid credentials and carries a WWW-Authenticate header. 403 means the caller is authenticated but not allowed. For resources whose existence is itself sensitive, returning 404 instead of 403 avoids confirming that an ID exists, and HTTP permits this explicitly. Whichever you choose, apply it uniformly, because inconsistent choices become an oracle.

For POST /v1/orders the resource being authorized, the customer_id, lives in the body. The server therefore parses the body syntactically, authorizes the referenced customer against the principal, and only then runs semantic validation. Enforce tenant ownership at the data layer, check scopes for privileged actions, use transport security, set explicit request-size limits, and keep bearer tokens and personal data out of logs. Rate limits protect capacity before any of this runs, but they do not replace authorization or abuse detection.

The stage after validation is where guarantees get hard. The business operation checks the state machine (a 409 if the order is already fulfilled) and the commit makes the result durable. Everything before the commit is cheap to retry because nothing has changed. After it, a lost response creates the unknown-outcome problem covered in the operations section below, which is why the response is the last stage and not part of the commit.

Errors Are Part of the Contract

Status codes, RFC 9457 problem documents, stable codes and request IDs

A failed request must be actionable by both software and people, so a good error separates concerns that serve different readers. The HTTP status tells proxies, gateways and SDKs the category and whether a retry could help. A stable application code is what client logic switches on, because it survives rewording and translation. The human message is for logs and developer tools and must never be parsed. Field-level details let a form highlight the exact input, and a request_id lets support join a customer report to server logs and traces.

Choose status codes by meaning. A small set covers nearly every everyday case:

200 / 201 / 204Success with a body, success that created a resource (add Location), success with nothing to return, typical for DELETE.
400The request could not be parsed or is structurally wrong.
401Credentials are missing or invalid. Send a WWW-Authenticate header.
403Authenticated, but not allowed to perform this action.
404No such resource at this URI, or its existence must not be disclosed.
409The request conflicts with current state: a duplicate, a lost update, an illegal transition.
422Well-formed content that violates the API’s rules, by convention.
429Rate limit exceeded. Include Retry-After where you can.
500 / 503Unexpected server failure, or temporary unavailability. Pair 503 with Retry-After when you know it.

RFC 9457 (which obsoletes RFC 7807) gives the error envelope a standard shape and the media type application/problem+json. The standard members are type (a URI identifying the problem class), title, status, detail and instance, and extension members are explicitly allowed.

A conflict as a problem document
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://example.com/problems/order-state-conflict",
  "title": "Order cannot be cancelled",
  "status": 409,
  "detail": "The order has already been fulfilled.",
  "code": "ORDER_STATE_CONFLICT",
  "request_id": "req_91a"
}

type, title, status and detail are standard members. code and request_id are extensions that this API documents itself.

For validation failures, add an extension such as an errors array of field and rule pairs. Problem documents are also where internals leak. Stack traces, SQL fragments, hostnames and account specifics belong in logs keyed by the request ID. A 500 carrying a raw database error hands an attacker your table names. Equally, never report a failure as 200 with {"success": false} in the body: CDNs may cache it, monitoring counts it as success, and SDKs proceed as though it worked. (GraphQL's convention of returning 200 with an errors array is a different case: a single response can carry partial data alongside per-field errors, so transport status cannot describe it, and you monitor the errors array instead.)

400 versus 422 is a convention you must document

The standards do not draw a sharp line. 400 means the server cannot or will not process the request because of something it perceives as a client error, including malformed syntax. 422 (named "Unprocessable Content" in current HTTP semantics, "Unprocessable Entity" in earlier documents) means the content was syntactically valid and understood but could not be processed. A common convention reserves 400 for bodies that are unparseable or structurally wrong, and uses 422 for semantic failures such as a quantity of zero or an unsupported currency. Many popular APIs use 400 for both. Both are defensible; inconsistency is not, because clients write handling logic against whichever you pick.

Collections: Pagination, Filtering and Sorting

A contract for scope, not just for shape

Any endpoint returning a collection, such as GET /v1/orders, needs a way to bound what comes back and usually a way to filter and order it: ?status=PENDING_PAYMENT, ?sort=-created_at, ?limit=20. Define a default and a maximum page size so no client can request everything in one call.

Offset versus cursor pagination is a real decision with performance and consistency consequences that depend on dataset size, growth rate and how tolerant the use case is of a page shifting under a concurrent write; the mechanics are in the Pagination lesson. For this lesson the point is that the choice belongs in the contract from day one, since retrofitting it into a live, publicly consumed endpoint is disruptive. Also document whether list results are eventually consistent and whether ordering is stable under concurrent writes.

Sorting and filtering deserve the same discipline. Validate the sort field against an allow-list, since an arbitrary client-supplied column name is both a performance risk and, in a naive implementation, an injection risk, and document which filters can be combined.

Evolution: Additive Change, Breaking Change and Versioning

Compatibility is judged from the client's side

The least disruptive change adds an optional response field while keeping existing fields and meanings stable. A change is breaking when an existing, well-behaved client stops working or starts misbehaving without changing anything itself: removing or renaming a field, changing a field's type or meaning, making an optional request field required, tightening validation on existing input, or changing what a status code means for an existing call. New optional fields, new endpoints and relaxed validation are usually safe. The rule underneath is that compatibility is judged from the client's side, and the client's side includes assumptions you never wrote down.

Enums are the clearest example and need a more careful statement than "new enum values are additive". Adding a value to a request enum is safe for existing clients, since they never send it. Adding a value to a response enum, such as a new order status PARTIALLY_SHIPPED, is only safe if every client handles unknown values gracefully. A client with an exhaustive switch that throws on the default branch crashes on the first such order. Treat response enums as open from day one: document that clients must tolerate unknown values and test that in the SDKs you control. If you cannot guarantee it for already deployed clients, adding a value is a breaking change and should be handled as one.

Additive versus breaking change at the API boundaryTwo clients share one order service: Mobile v1, which cannot be updated quickly, and Web v2, which ships with the server. In the additive case the new currency field is optional with a server-side default, so both clients succeed. In the breaking case currency becomes required, Web v2 still succeeds, but Mobile v1 is rejected with a 400 before the order service is reached.Additive: currency is optional, the server applies a defaultMobile v1installed, cannot updateWeb v2deployed with serverAPI boundaryaccepts both shapesOrder serviceone implementationno currency: defaultedcurrency: INRBreaking: currency becomes required for everyoneMobile v1installed, cannot updateWeb v2deployed with serverAPI boundary400 if currency is absentOrder serviceone implementationno currency: rejectedcurrency: INRv1 stops at the boundary: healthy server, broken app
Additive versus breaking change at the API boundary. Two clients share one order service: Mobile v1, which cannot be updated quickly, and Web v2, which ships with the server. In the additive case the new currency field is optional with a server-side default, so both clients succeed. In the breaking case currency becomes required, Web v2 still succeeds, but Mobile v1 is rejected with a 400 before the order service is reached.

The figure shows one new requirement introduced two ways. In the additive lane the boundary accepts both request shapes and supplies a default where business rules permit. In the breaking lane it rejects anything without the field. Web v2 succeeds in both, which is exactly why this class of failure survives testing: the tests run the current client. A classic version of the same incident is renaming name to firstName in place, after which every mobile build still reading name crashes.

Versioning mechanisms

When a change genuinely must break clients, versioning lets old and new behavior coexist while they migrate. URI versioning (/v1/orders, /v2/orders) is the most visible option, and routing and gateways can dispatch on the path alone. Header or media-type versioning (Accept: application/vnd.example.v2+json, or a custom header) keeps one canonical URL per resource, at the cost of being less visible in logs and harder to try with a bare URL; if shared caches are involved the response must carry Vary for the negotiating header.

A version number does not create compatibility by itself. You still need a migration window, deprecation communication, usage measurements and tests against older clients, and the old version must keep genuinely working until the window closes instead of decaying silently. The preferred sequence is to expand the contract, migrate consumers, and retire the old behavior only when the measurements say it is safe.

Production Scenario: A Breaking Change During a Mobile Rollout

Healthy server, broken customer experience

A mobile app sends {"customer_id":"cus_12"} to create an order. A server release starts requiring currency and rejects requests without it using 400. Dashboards show a healthy fleet: CPU is flat, latency is low, and every rejection is a "client error" that most alerting ignores. Meanwhile every customer on the old app build cannot check out, and nothing on the server recovers that until they update.

The safer path has distinct steps. Make currency optional and default it where business rules truly allow, for example from the customer's market. Release the updated client. Measure adoption, such as the share of order requests that omit the field, broken down by a bounded client-version label. Announce a deprecation window. Only then enforce the requirement, as a deliberate version transition rather than a silent tightening. Mobile installs persist for months, so the old shape may never reach zero; the decision to enforce is a product decision about forced-upgrade policy, not purely an engineering one.

Safe rollout of a new request field (illustrative timeline)Illustrative day-by-day plan. The server first accepts requests with or without currency. The updated mobile app rolls out in stages while old installs keep sending the old shape for months. Enforcement is considered only after measured traffic from old versions falls below an agreed threshold, and then as a new API version rather than a silent change.0306090120150180days (illustrative)Server: currency optional + defaultstays until retirementMobile v2 staged rolloutsends currencyOld installs still callinglong tailMeasure by client versionAnnounce deprecation windowEnforce in /v2 onlydeliberate
Safe rollout of a new request field (illustrative timeline). Illustrative day-by-day plan. The server first accepts requests with or without currency. The updated mobile app rolls out in stages while old installs keep sending the old shape for months. Enforcement is considered only after measured traffic from old versions falls below an agreed threshold, and then as a new API version rather than a silent change.

Compatibility is a property of the whole consumer ecosystem, not of the server's current schema file. An OpenAPI document can validate perfectly against the new server while describing a contract that deployed clients violate.

Timeouts, Retries and Observing the Contract

The operational half of an API contract

A contract should state timeouts, request limits, pagination behavior, rate limits and which operations may be retried. The case that deserves the most care is a client that times out after sending POST /v1/orders. Networks fail after the work succeeds, not only before it, so the client cannot tell whether the order was created and only the response was lost, or whether nothing happened. That is an unknown outcome, not proof of failure, and treating it as failure is how double orders and double charges happen.

A timeout after commit leaves the outcome unknownThe client sends POST /v1/orders with an Idempotency-Key. The server commits the order, but the 201 response is lost in the network, so the client's timeout fires without knowing whether the order exists. The client retries with the same key, and the server replays the stored result instead of creating a second order.Clientmobile appAPIboundaryOrder storedurable1. POST /orders, key K2. insert order + key K3. commit ok: ord_8f324. 201 lost in network5. timeout: outcome unknown6. retry POST, same key K7. lookup key K8. replay 201 ord_8f32
A timeout after commit leaves the outcome unknown. The client sends POST /v1/orders with an Idempotency-Key. The server commits the order, but the 201 response is lost in the network, so the client's timeout fires without knowing whether the order exists. The client retries with the same key, and the server replays the stored result instead of creating a second order.

There are two honest ways out. The first is an idempotency key sent on the original request and every retry, so the server executes the operation once and replays the stored result. The header format is currently an IETF draft rather than a published RFC, so document your own semantics precisely: scope, retention, and behavior when the same key arrives with a different payload. The second is an operation-status lookup, such as reading the order by a client-supplied reference. Neither alone gives exactly-once side effects across every downstream system; they make the API's own effect idempotent. The mechanics are in the Idempotency lesson, and the client-side schedule for retrying belongs to Retry & Backoff.

Instrumentation that does not hurt the metrics system

Define a latency objective at the API boundary and record request duration and error rate by route template and status class. Use the template (/v1/orders/:id), not the raw path. Every distinct label value creates a separate time series, so putting a raw customer ID, order ID or request ID on a metric multiplies storage and query cost until the monitoring system degrades or drops data. Keep labels bounded (route, method, status class, client-version bucket) and put high-cardinality identifiers in logs and traces, where the request_id and trace ID tie everything together across service calls.

Choosing REST, GraphQL or gRPC

Decide from consumer constraints, not popularity

Pick the style from who the consumers are and what you control. HTTP resource APIs are often the strongest default for public integrations because clients, gateways, caches and developer tools already understand them, and the simplicity and cacheability of safe reads are an operational advantage for an audience you cannot coordinate with. GraphQL fits when web and mobile clients need different shapes of the same data and you cannot predict every screen's response in advance. It trades server-side complexity for client freedom, and brings query-cost governance, resolver performance concerns and harder caching. gRPC fits when you control both ends of a service-to-service call, care about throughput and latency, and want schema enforcement through Protobuf and streaming; browser access typically needs gRPC-Web or a proxy, so it usually stays on internal traffic.

Choosing an API style from consumer constraintsThe deciding question is who calls the API. External and partner clients that need standard HTTP tooling point to a resource-oriented HTTP API. Many different user interfaces needing varied graph-shaped reads point to GraphQL. Internal callers where you control both ends and want typed stubs or streaming point to gRPC. The styles are commonly combined: a public HTTP edge in front of internal gRPC services.Who calls this API?and what do they control?HTTP resource APIunknown or partner clients, CDNs, curlGraphQLmany UIs, varied graph-shaped readsgRPCinternal calls, typed stubs, streamingOften combinedpublic HTTP edge, gRPC between servicesexternalflexible readsyou own both ends
Choosing an API style from consumer constraints. The deciding question is who calls the API. External and partner clients that need standard HTTP tooling point to a resource-oriented HTTP API. Many different user interfaces needing varied graph-shaped reads point to GraphQL. Internal callers where you control both ends and want typed stubs or streaming point to gRPC. The styles are commonly combined: a public HTTP edge in front of internal gRPC services.
DimensionHTTP resource APIGraphQLgRPC
Unknown or browser clientsNative fitNative fit (one POST endpoint)Needs gRPC-Web or a proxy
Per-screen response shapesOver-fetching or extra endpointsClient picks fieldsFixed messages
HTTP caching and CDNsWorks with GET and Cache-ControlNeeds persisted queries or custom layersNot a goal
StreamingSSE or WebSockets bolted onSubscriptionsBuilt in, four call types
Typed code generationVia OpenAPIVia schemaCore workflow (Protobuf)
Main operational riskChatty clients, version sprawlExpensive queries, resolver N+1Contract drift across stubs

Team familiarity is a real cost too: a team fluent in schema-first RPC ships gRPC faster, and one used to HTTP semantics ships REST faster. The styles also coexist. An external HTTP API can call internal gRPC services, and the public contract should not expose internal service topology merely because the implementation uses RPC. The gRPC lesson covers Protobuf and streaming; when REST is the choice, the REST lesson covers the constraints that make it pay off.

Putting It Together: Adding Partial Refunds

One feature, every decision from this lesson

Suppose the order service must support partial refunds. Start with the workflow: a support agent or a customer refunds part of a confirmed order, and the money returns later. The operation's meaning is therefore "a refund request was durably accepted", not "the money moved", so the response needs a status.

Model it as POST /v1/orders/ord_8f32/refunds with a body of amount_minor and reason, returning 201, a Location and a refund with status PENDING. The currency comes from the order, so it is never sent. Authorize against the order within the caller's tenant, requiring a refunds:create scope. Validate that the amount is positive and does not exceed what remains refundable; the former is a 422 by this API's convention, while an order that is not in a refundable state is a 409 with a stable code such as ORDER_NOT_REFUNDABLE. Because this moves money, require an Idempotency-Key and define that replaying it with a different amount is rejected rather than silently accepted. A client that times out reads GET /v1/orders/ord_8f32/refunds to learn the outcome.

For evolution, add the endpoint and refund statuses as additions, document that unknown refund statuses must be tolerated, and list refunds with a cursor and a maximum page size. Emit a route-template metric and log the request ID. Nothing here changes an existing contract, so no version bump is needed. The review questions that produced that design generalize:

Operation meaningWhat does success promise: accepted, committed, completed? Is the state visible in the response?
AuthorizationIs every object-level read and write scoped to the caller inside the data-access layer?
ValidationAre required fields, enums, lengths, array sizes and defaults specified, with a documented 400 versus 422 rule?
ErrorsProblem document, stable code, request ID, field errors, nothing internal leaked, real status codes.
CollectionsDefault and maximum page size, pagination strategy, allow-listed sort and filter fields.
RetriesWhich operations are safe to repeat, and what do clients do after a timeout on the rest?
EvolutionAre response enums documented as open? Is there a deprecation policy and per-client-version telemetry?
ObservabilityRoute-template metrics with bounded labels, request IDs in responses, trace propagation.

Where to Go Next

Follow the contract into the protocol and into failure handling

The REST lesson explains the constraints behind the resource model used here, and the gRPC lesson shows how Protobuf evolution rules differ from JSON. Collections continue in Pagination, and unknown outcomes in Idempotency. Once clients retry, you will want Retry & Backoff, Circuit Breaker and Rate Limiting to keep them from overwhelming the service they depend on.