Module 3 · API design

Lesson 14 — Idempotency and versioning

Backwards compatibility and the Idempotency-Key that avoids charging twice.

Published
In this lesson
  1. Objectives
  2. 1. The problem: the retry that charges twice
  3. 2. Idempotency by design (free, no table)
  4. 3. Backward compatibility: what you can change without breaking
  5. 4. Idempotency on the webhook (its twin)
  6. Self-assessment

Stack: DRF · Project: TicketFlow Status: Published Prerequisite: Lesson 13 — REST done right


Objectives

  1. Implement Idempotency-Key for real: store the response, replay the same one, expire keys.
  2. Design naturally idempotent endpoints (PUT, DELETE) and protect the ones that can't be (the payment POST).
  3. Evolve the API without breaking clients (backward compatibility and versioning).

1. The problem: the retry that charges twice

The client sends POST /reservations/{id}/payments/, the network times out, it retries — and the server already processed the first one: two payments. Neither your code nor the queue prevents it: the client retries legitimately (networks fail). The answer is the idempotency contract:

POST /api/reservations/{uuid}/payments/
Idempotency-Key: 7f3c9a2e-...     ← generated by the CLIENT, unique per intent

Server side:

  1. The key didn't exist → process it, store the response associated with the key (with its status code).
  2. The key exists with the same request body → return the stored response (without reprocessing).
  3. The key exists with a different body → 422 Unprocessable (misuse: the key belongs to one intent).
  4. Old keys expire (24h TTL) — they are a cache of intents, not a forever log.

Storage: a idempotency_key(key PK, request_hash, response_status, response_body, created_at) table — transactional with the processing (Lesson 10): the key and the effect land in the same commit.

2. Idempotency by design (free, no table)

OperationIdempotent?Why
PUT /events/{uuid} (full replacement)yesthe same PUT twice leaves the same state
DELETE /reservations/{uuid} (cancel)yes if repeating is a no-op00b's cancel() already returned False
GETyesmutates nothing
POST /reservations/NOT by design (creates a new resource)the intent-key protects it
POST /payments/NO (it charges)the intent-key is MANDATORY

The project's I3 ("paid exactly once") holds through two layers: idempotency_key UNIQUE on Payment (DB) + this lesson's mechanism (API). Defense in depth again.

3. Backward compatibility: what you can change without breaking

Safe (old clients keep working): adding fields to responses; adding endpoints; adding optional parameters with defaults; adding new values the old client ignores.

Breaking (requires a version): removing or renaming fields; changing types (string→int); changing an error code's semantics; making a parameter required; changing the pagination format.

Operational rule: clients don't upgrade when you want them to (installed mobile apps, integrators' scripts). Every breaking change = a new major version coexisting:

/api/v1/events/   (frozen, security bugfixes)
/api/v2/events/   (alive)

Versioning in the path (not in a header: it is easier to share and debug). Announced deprecation: a Deprecation header + a documented cutoff date, and per-version usage metrics before shutting v1 down (Lesson 46).

4. Idempotency on the webhook (its twin)

The gateway retries its webhooks (at-least-once, Lesson 30): the same payment event may arrive 3 times. Your handler: verify the signature → look up payment.idempotency_key = the gateway's event_id → if it exists, answer 200 and do nothing (the successful retry is expected, not an error). This client/gateway symmetry is the reason I3 exists.


Self-assessment

  1. Why is the idempotency key generated by the CLIENT and not the server?
  2. What does the server store exactly to answer idempotently, and why is the request_hash mandatory?
  3. Why does the key table live in the same transaction as the effect (Lesson 10)? Which bug does separating them produce?
  4. List 3 "safe" and 3 "breaking" changes to your current API.
  5. How would you shut down v1 with data in hand and without breaking anyone abruptly?

Continue with the exercises. The solutions only after trying it yourself.