API design: contract, versions, retried requests
In short: A good API is a contract: resources, methods, statuses and fields the client relies on; an idempotency key makes a retried request safe, and versions add without breaking.
Problem
Paid twice
A customer pays for order 1001 from the mobile app. The app sends a request to the shop's API, the server charges 120 €, but the connection drops before the response arrives. The app does not know whether it paid. So it does what any sensible client does: it tries again.
The server charges like this:
class PaymentApi { payments = new Payments();
handle(req) { return this.payments.charge(orderOf(req), req.amount); }}And the app tries up to three times:
function pay(api, order, amount) { const path = `/orders/${order}/payments`; const req = { method: "POST", path, key: null, amount }; for (let attempt = 1; attempt <= 3; attempt++) { const res = network.send(api, req); if (res !== null) { console.log(`attempt ${attempt}: ${res}`); return res; } console.log(`attempt ${attempt}: the response is lost`); } return null;}The network is a recorded script: the response to the first call is lost. The server has done the work; only the response is lost.
const api = new PaymentApi();pay(api, 1001, 120);console.log(api.payments.report());attempt 1: the response is lostattempt 2: 201 paid 1001 (pay-2)payments: 2, total 240 €The customer sees "paid" once, and 240 € have left their card. The server has no way to tell that the second request is the same payment: for it, this is a second payment. A lost response is not rare: a mobile network in the subway, a lift, a proxy timeout.
The idea
An API is a contract
An API is a promise to its clients: what is at which address, what it accepts and what it returns. The client is written against that promise, often by another team or another company, and cannot change together with the server. That promise is the API contract.
In an HTTP API the contract is built from a few parts:
- a resource is something with an address:
/orders/1001is an order,/orders/1001/paymentsare its payments. The address names a thing, not an action:/payments, not/makePayment; - the method says what is done with the resource:
GETreads,POSTcreates,PUTwrites the whole thing,PATCHchanges a part,DELETEdeletes; - the status says how it went:
200fine,201created,400bad request,404no such resource,409conflict,422the request is understood but cannot be carried out,5xxthe error is in the server; - the fields of the request and the response, with their names and types.
GET, PUT and DELETE are idempotent by the rules of HTTP: the second time they give the same result. That is why clients and proxies retry them freely. POST is not: every POST creates something new. A payment is a POST, so retrying it charges again.
You saw what an HTTP request and response look like in the network lesson.
See it
Request, response and network in memory
There is no HTTP server here. The API is an object with a method handle: it takes a request and returns a response. The request carries a method, a path, a key and an amount, and the response carries a status and a text:
// Request is a plain object: { method, path, key, amount }// /orders/1001/payments → 1001const orderOf = (req) => Number(req.path.split("/")[2]);
class Response { constructor(status, body) { this.status = status; this.body = body; }
toString() { return `${this.status} ${this.body}`; }}The network passes the request to the API and, by the script, loses the response:
// a recorded script: on which calls the response is lostclass Network { constructor(...lost) { this.lost = new Set(lost); this.calls = 0; }
send(api, req) { this.calls++; const res = api.handle(req); // the server has done the work; only the response is lost return this.lost.has(this.calls) ? null : res; }}The payments remember the amounts:
class Payments { amounts = [];
charge(order, amount) { this.amounts.push(amount); const n = this.amounts.length; return new Response(201, `paid ${order} (pay-${n})`); }
report() { const sum = this.amounts.reduce((a, b) => a + b, 0); return `payments: ${this.amounts.length}, total ${sum} €`; }}The idea
An idempotency key
The server has to tell "the same payment, tried again" from "a new payment". It cannot do it alone: the two requests look the same. So the client tells it. Before the first attempt the client makes a unique idempotency key (usually a UUID) and sends it with every attempt, in the Idempotency-Key header.
The server keeps "key → response". If the key is new, the server does the work and remembers the response. If the key has been seen, the server does nothing and returns the saved response. The client gets the same thing it would have got the first time.
This is the idea of the idempotent consumer from the events lesson, turned towards the client: there the consumer remembers the event id, here the server remembers the request key.
See it
The retried payment with a key
The client makes the key once, before the loop:
let keys = 0;
function payWithKey(api, order, amount) { // one key for all attempts; a real client uses a UUID const key = `k-${++keys}`; const path = `/orders/${order}/payments`; const req = { method: "POST", path, key: key, amount }; for (let attempt = 1; attempt <= 3; attempt++) { const res = network.send(api, req); if (res !== null) { console.log(`attempt ${attempt}: ${res}`); return res; } console.log(`attempt ${attempt}: the response is lost`); } return null;}The server checks the key before charging:
class IdempotentApi { payments = new Payments(); // key → the response already given done = new Map();
handle(req) { if (req.key == null) return new Response(400, "no Idempotency-Key"); if (this.done.has(req.key)) return this.done.get(req.key); const res = this.payments.charge(orderOf(req), req.amount); this.done.set(req.key, res); return res; }}The same script: the first response is lost.
const api = new IdempotentApi();payWithKey(api, 1001, 120);console.log(api.payments.report());attempt 1: the response is lostattempt 2: 201 paid 1001 (pay-1)payments: 1, total 120 €class IdempotentApi { payments = new Payments(); // key → the response already given done = new Map();
handle(req) { if (req.key == null) return new Response(400, "no Idempotency-Key"); if (this.done.has(req.key)) return this.done.get(req.key); const res = this.payments.charge(orderOf(req), req.amount); this.done.set(req.key, res); return res; }}function pay(api, order, amount) { const path = `/orders/${order}/payments`; const req = { method: "POST", path, key: null, amount }; for (let attempt = 1; attempt <= 3; attempt++) { const res = network.send(api, req); if (res !== null) { console.log(`attempt ${attempt}: ${res}`); return res; } console.log(`attempt ${attempt}: the response is lost`); } return null;}class PaymentApi { payments = new Payments();
handle(req) { return this.payments.charge(orderOf(req), req.amount); }}let keys = 0;
function payWithKey(api, order, amount) { // one key for all attempts; a real client uses a UUID const key = `k-${++keys}`; const path = `/orders/${order}/payments`; const req = { method: "POST", path, key: key, amount }; for (let attempt = 1; attempt <= 3; attempt++) { const res = network.send(api, req); if (res !== null) { console.log(`attempt ${attempt}: ${res}`); return res; } console.log(`attempt ${attempt}: the response is lost`); } return null;}class IdempotentApi { payments = new Payments(); // key → the response already given done = new Map();
handle(req) { if (req.key == null) return new Response(400, "no Idempotency-Key"); if (this.done.has(req.key)) return this.done.get(req.key); const res = this.payments.charge(orderOf(req), req.amount); this.done.set(req.key, res); return res; }}Step by step, in words
- Scenario 1, no key: the phone sends POST /orders/1001/payments for 120 €.
- The server charges 120 € (pay-1) and answers 201.
- The connection drops: the response is lost on the way. The phone does not know whether it paid.
- The phone tries again with the same request.
- The server cannot tell it is the same payment: it charges another 120 € (pay-2). 240 € in total.
- Scenario 2, with a key: the client makes the key k-1 once and sends it with every attempt.
- The key is new: the server charges 120 € and remembers k-1 → its response.
- The response is lost again.
- The retry carries the same key, k-1.
- The server finds k-1 and returns the saved response, pay-1, without charging again.
- No key: 2 payments, 240 €. With a key: 1 payment, 120 €.
The second attempt gets pay-1, the same response as the first, and the money is charged once. Without a key the server refuses with 400: a payment without a key cannot be retried safely, so this API does not accept it. Some APIs (Stripe, for example) accept requests without a key too, but then they promise nothing on a retry.
Try it
Find the bug
A colleague rearranges the client and the key ends up in the loop. The API is the same, with the key:
attempt 1: the response is lostattempt 2: 201 paid 1001 (pay-2)payments: 2, total 240 €Two payments again. Which line has the bug?
Pick the line with the bug.
The key is made on every attempt, so the second attempt carries k-2. For the server that is a new operation, and it charges again. The key names the payment, not the attempt: it is made once, before the loop, and kept until the payment gets a response. A real client keeps it even across a restart of the app.
The idea
A new version without breaking old clients
The mobile app from last month is still on thousands of phones and will not update today. Every change to the API has to work with it too. Can the change break an old client? Check the contract of the response: every old field has to be there, with the same type.
// the contract of the response: field name and typefunction check(old, next) { const problems = []; for (const [name, type] of Object.entries(old)) { if (!(name in next)) problems.push(`missing ${name}`); else if (next[name] !== type) problems.push(`${name}: ${type} → ${next[name]}`); } if (problems.length === 0) return "✓ does not break"; return "✗ breaks: " + problems.join(", ");}const v1 = { id: "string", status: "string", amount: "number" };const added = { ...v1, currency: "string" };const renamed = { id: "string", status: "string", total: "number" };const retyped = { ...v1, amount: "string" };console.log("added field currency: " + check(v1, added));console.log("amount became total: " + check(v1, renamed));console.log("amount became text: " + check(v1, retyped));added field currency: ✓ does not breakamount became total: ✗ breaks: missing amountamount became text: ✗ breaks: amount: number → stringThe rules that come out of this:
- add, do not remove. A new field in the response is safe: the old client does not read it. A removed, renamed or retyped field breaks;
- in the request it is the other way round. A new required field in the request breaks old clients, because they do not send it. A new field has to be optional, with a default value;
- let the client be tolerant. A client that fails on an unknown field or on a new value in a list of statuses breaks itself. The client reads only what it needs and skips the rest;
- a breaking change is a new version.
/v2/ordersnext to/v1/orders, both working until old clients move over. The old version is announced as deprecated with a date, then switched off.
The idea
Paging: offset or cursor
There are thousands of orders, and the app shows 20 at a time. The simplest paging is offset: "skip the first 40, give me 20". The cursor is the other way: "give me 20 after order 1007". The cursor is the last id of the previous page, and the server returns it in the response so that the client can ask for the next page.
// the orders, newest firstconst orders = [1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001];
function byOffset(offset, limit) { return orders.slice(offset, offset + limit);}
// the cursor: the last id of the previous pagefunction afterCursor(cursor, limit) { return orders.filter((id) => id < cursor).slice(0, limit);}
const show = (page) => `[${page.join(", ")}]`;The client reads the first page, and a new order arrives between the two pages:
const page1 = byOffset(0, 3);console.log(`page 1: ${show(page1)}`);orders.unshift(1010);console.log("a new order arrives 1010");console.log(`offset, page 2: ${show(byOffset(3, 3))}`);const cursor = page1.at(-1);console.log(`cursor, page 2: ${show(afterCursor(cursor, 3))}`);page 1: [1009, 1008, 1007]a new order arrives 1010offset, page 2: [1007, 1006, 1005]cursor, page 2: [1006, 1005, 1004]With offset, order 1007 comes twice: the new order has pushed everything down. With a cursor the second page starts right after 1007, whatever arrived at the top.
The cursor is also fast in the database. OFFSET 10000 makes the database read and throw away 10 000 rows before it returns 20. WHERE id < 1007 ORDER BY id DESC LIMIT 20 goes straight to the place through the index. The price of the cursor: you cannot jump to page 7, and the number of pages does not come for free.
In a real API the cursor is an opaque string (for example "eyJpZCI6MTAwN30", the encoded {"id":1007}), so that the client does not rely on what is inside and the server can change it.
Try it
Predict the output
Now order 1008 is deleted between the two pages.
The last line of the output is:
const page1 = byOffset(0, 3);console.log(`page 1: ${show(page1)}`);orders.splice(orders.indexOf(1008), 1);console.log("order 1008 is deleted");console.log(`offset, page 2: ${show(byOffset(3, 3))}`);After the delete 1007 is second, 1006 is third and 1005 is fourth. Offset 3 skips 1009, 1007 and 1006, so 1006 is shown on no page. A delete while reading skips a row, an insert repeats one. The cursor "after 1007" would have returned 1006, 1005, 1004.
The idea
When this hurts
An idempotency key costs a table of keys, a check on every request and cleanup (keys are usually kept for about a day). Do not add it:
- to requests that are already idempotent.
GET,PUTwith a value,DELETE, repeated, do not do a second thing; - where running twice does no harm. "Mark as read" twice is the same as once.
A cursor is unnecessary when the list is small and hardly changes (the shop's categories), or when people really do jump to page 7 (a table in an admin panel). There offset is simpler and honest.
An API version is unnecessary while all clients are in the same repository and are released together with the server: then change them together. Versions are for clients you do not control. Every supported version is code you have to test and keep, so do not make a v2 for a change that can be compatible.
And do not chase "pure REST" at any price. POST /orders/1001/cancel is clearer than an artificial "cancellation" resource. The contract has to be clear and stable, not pretty.
How an API gateway is run in front of the services (TLS, rate limiting, routes) is a topic for the DevOps course ZeroToOps.
More precisely, if you are curious
The standards. The Idempotency-Key header is described in an IETF draft ("The Idempotency-Key HTTP Header Field"): the same key with another request is 422, and a retry while the first request is still running is 409. Stripe, Adyen and PayPal use keys for their payments. Errors in JSON usually follow RFC 9457 (Problem Details): type, title, status, detail.
The contract as a file. OpenAPI describes the paths, fields and statuses in YAML. Clients and documentation are generated from it, and tools such as oasdiff compare two versions and tell which change breaks, just like check in this lesson. Pact checks the contract from the client's side (consumer-driven contract tests).
By language. Express has nothing built in, NestJS has @nestjs/swagger for OpenAPI. The keys are kept in the database or in a separate store.
Not only REST. GraphQL has one address and the client says which fields it wants, so adding fields is natural, and cursors are in the standard (Relay connections). gRPC describes the contract in .proto files with numbered fields: a new field with a new number does not break, a reused number breaks.
A cursor with repeats in the key. If you sort by date, two orders can have the same date. The cursor is then the pair (date, id), otherwise rows are lost at the border between pages.
Exercise
The same key, another payment
Solve this in your own editor.
A buggy client sends the key k-1 for a payment of 120 €, then the same key for a payment of 90 €. Now the server returns the saved response for 120 €, and the client thinks it paid 90 €. Change IdempotentApi so that it also remembers the request: the same key with the same request returns the saved response, and the same key with another request is 422. Send three requests (120 €, 120 € again, 90 €) and the report:
201 paid 1001 (pay-1)201 paid 1001 (pay-1)422 key k-1 belongs to another requestpayments: 1, total 120 €More tasks
- Expiry. Keep a creation tick with the key. A key older than 100 ticks is forgotten, and a request with it is new.
- A response to an error. If the payment throws an error (the card is declined), is the response remembered? Decide and justify: should a retry after
402try again? - A cursor by date. Make the orders pairs (date, id) with two orders on the same date and a page of 1. Show that a cursor by date alone loses an order, and a cursor (date, id) does not.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Array methods · Data, switch and destructuring · Network programming · Events and queues
- Continues in: ADR: architecture decision records · When the neighbour is slow: timeout, retry, circuit breaker · A transaction across services (saga) · Integrating old and foreign systems
- Tasks on this topic: Software architecture