Skip to content
wannacode

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());
Output
attempt 1: the response is lost
attempt 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/1001 is an order, /orders/1001/payments are its payments. The address names a thing, not an action: /payments, not /makePayment;
  • the method says what is done with the resource: GET reads, POST creates, PUT writes the whole thing, PATCH changes a part, DELETE deletes;
  • the status says how it went: 200 fine, 201 created, 400 bad request, 404 no such resource, 409 conflict, 422 the request is understood but cannot be carried out, 5xx the 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 → 1001
const 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 lost
class 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());
Output
attempt 1: the response is lost
attempt 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;
}
}
Step by step, in words
  1. Scenario 1, no key: the phone sends POST /orders/1001/payments for 120 €.
  2. The server charges 120 € (pay-1) and answers 201.
  3. The connection drops: the response is lost on the way. The phone does not know whether it paid.
  4. The phone tries again with the same request.
  5. The server cannot tell it is the same payment: it charges another 120 € (pay-2). 240 € in total.
  6. Scenario 2, with a key: the client makes the key k-1 once and sends it with every attempt.
  7. The key is new: the server charges 120 € and remembers k-1 → its response.
  8. The response is lost again.
  9. The retry carries the same key, k-1.
  10. The server finds k-1 and returns the saved response, pay-1, without charging again.
  11. 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:

Output
attempt 1: the response is lost
attempt 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 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 type
function 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));
Output
added field currency: ✓ does not break
amount became total: ✗ breaks: missing amount
amount became text: ✗ breaks: amount: number → string

The 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/orders next 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 first
const 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 page
function 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))}`);
Output
page 1: [1009, 1008, 1007]
a new order arrives 1010
offset, 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))}`);
Answer

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, PUT with 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:

Output
201 paid 1001 (pay-1)
201 paid 1001 (pay-1)
422 key k-1 belongs to another request
payments: 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 402 try 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

1. The response to a payment POST is lost. What should the client do to retry safely?
2. Which of these changes to the response does NOT break old clients?
3. Between two offset pages a new order arrives at the top. What does the client see on the second page?
4. When is an idempotency key unnecessary?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·