Jour 28 Day 28 · jeudi 10 septembre 2026 Thursday 10 September 2026 Architecture Intermédiaire
Bien concevoir une API Designing a good API
Nommage, codes de statut, pagination, idempotence, webhooks : les conventions qui distinguent une API agréable d'une API subie — et l'exercice de design le plus posé en entretien. Naming, status codes, pagination, idempotency, webhooks: the conventions that separate an API people enjoy from one they endure — and the most common design exercise in interviews.
L’essentiel
Une API est un contrat entre votre serveur et des clients que vous ne contrôlez pas. Sa qualité ne se mesure pas à ce qu’elle fait, mais à sa prévisibilité : un développeur qui a vu un seul endpoint doit pouvoir deviner les autres. Les conventions REST existent précisément pour ça — les respecter, c’est offrir gratuitement des années d’intuition accumulée par l’écosystème.
Les règles de base tiennent en trois lignes :
- Les ressources sont des noms au pluriel, jamais des verbes :
GET /users/42/orders, pasGET /getOrdersOfUser?id=42. Le verbe, c’est la méthode HTTP. - Les méthodes HTTP portent la sémantique :
GETlit (sans effet de bord),POSTcrée,PUTremplace,PATCHmodifie partiellement,DELETEsupprime. - Les codes de statut disent la vérité : 2xx succès, 4xx erreur du client, 5xx erreur du serveur.
401= non authentifié,403= authentifié mais interdit,404= introuvable,409= conflit d’état,422= payload valide syntaxiquement mais invalide métier.
⚠️ Le 200 qui ment — l’anti-pattern le plus répandu : répondre
200 OKavec{"success": false, "error": "..."}dans le corps. Les proxies mettent la réponse en cache, les métriques croient que tout va bien, les clients doivent parser le corps pour savoir si ça a marché, et les retries automatiques ne se déclenchent jamais. Le code de statut EST le canal d’erreur, utilisez-le.
Comment ça marche
Erreurs structurées — Une erreur doit être exploitable par une machine ET lisible par un humain. Le standard, c’est RFC 9457 (Problem Details) avec le content-type application/problem+json :
{
"type": "https://api.example.com/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Requested 5 units of item 4521, only 2 left.",
"instance": "/orders/abc123",
"available": 2
}
type identifie la catégorie d’erreur (une URL stable, documentable), detail explique ce cas précis, et on peut ajouter des champs métier (available). Un client peut brancher sur type sans parser un message en anglais.
Versioning — Deux écoles : la version dans l’URL (/v1/users, visible, simple à router et à cacher) ou dans un header (Accept: application/vnd.api+json;version=2, plus « pur » REST mais invisible et pénible à tester dans un navigateur). En pratique, l’URL gagne presque partout (Stripe, GitHub). Le vrai réflexe senior : ne versionner que sur breaking change. Ajouter un champ dans une réponse n’est pas cassant — les clients doivent ignorer les champs inconnus. Renommer ou supprimer un champ, changer un type : ça, c’est cassant.
Pagination — Ne jamais renvoyer une collection entière. Deux stratégies :
Offset (?page=3&limit=20) | Cursor (?after=xyz&limit=20) | |
|---|---|---|
| Implémentation | Triviale (LIMIT/OFFSET) | Keyset sur colonne indexée |
| Page profonde | Lente (la DB scanne et jette) | Rapide (index seek direct) |
| Insertions pendant le parcours | Doublons ou trous | Stable |
| Saut à la page N | Oui | Non (parcours séquentiel) |
| Usage type | Back-office, petites tables | Feeds, API publiques, gros volumes |
Le cursor encode « où j’en suis » (souvent le dernier id/timestamp, opaque en base64) : la requête devient WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 20, servie par l’index quelle que soit la profondeur.
// GET /todos?after=<cursor>&limit=20 — pagination par cursor
app.get("/todos", async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100); // borner !
const after = decodeCursor(req.query.after); // { createdAt, id } opaque
const rows = await db.todos.find({
where: after ? { createdAt: { lt: after.createdAt } } : {},
orderBy: { createdAt: "desc" },
take: limit + 1, // +1 pour savoir s'il reste une page
});
const hasMore = rows.length > limit;
const items = rows.slice(0, limit);
res.json({
data: items,
next_cursor: hasMore ? encodeCursor(items.at(-1)) : null,
});
});
Filtrage et tri — en query params, conventions simples : ?status=open&sort=-created_at (le - pour descendant). Documenter les champs filtrables ; tout accepter aveuglément, c’est offrir des full scans à vos utilisateurs.
Idempotence — GET, PUT, DELETE sont idempotents par définition (rejouer = même état). POST ne l’est pas : un client qui timeout et retry peut créer deux commandes. La solution : le header Idempotency-Key (popularisé par Stripe). Le client envoie une clé unique par opération ; le serveur stocke clé → réponse et rejoue la réponse enregistrée si la clé revient. Le retry devient sûr.
Rate limiting — répondre 429 Too Many Requests avec un header Retry-After (secondes ou date), plus les headers informatifs RateLimit-Limit / RateLimit-Remaining. Un client bien élevé lit Retry-After et applique un backoff exponentiel avec jitter.
Webhooks — l’API dans l’autre sens : c’est vous qui appelez le client quand un événement survient (paiement validé, build terminé).
Votre API ──POST /hooks (event + signature)──▶ Client
│ │
│◀────────── 2xx reçu ? sinon retry ──────────┘
│ backoff expo : 1min, 5min, 30min…
Les règles du jeu : signer le payload en HMAC-SHA256 avec un secret partagé, en incluant un timestamp pour bloquer le rejeu (le client vérifie signature + fraîcheur) ; réessayer avec backoff exponentiel tant que le client ne répond pas 2xx ; côté client, répondre 2xx immédiatement et traiter en asynchrone — et dédupliquer par event.id, car les retries garantissent de l’at-least-once, donc des doublons.
Documentation — une spec OpenAPI n’est pas un luxe : elle génère la doc interactive (Swagger UI), les clients typés, les mocks et les tests de contrat. Spec-first ou code-first, peu importe — l’important est qu’elle soit la source de vérité.
Concepts clés à maîtriser
- Ressource vs action : quand une opération ne rentre pas dans le CRUD (
annuler une commande), on modélise une sous-ressource ou une action :POST /orders/42/cancel. Pragmatisme > pureté. - 401 vs 403 : « je ne sais pas qui tu es » vs « je sais qui tu es, et non ». Les confondre en entretien coûte cher.
- Champs inconnus ignorés : c’est le contrat implicite qui rend les ajouts non cassants. Un client qui rejette les champs inconnus se casse tout seul.
- Enveloppe de réponse :
{ "data": [...], "next_cursor": ... }plutôt qu’un tableau nu — un tableau nu ne peut plus jamais accueillir de métadonnées sans breaking change. - HATEOAS : savoir dire que ça existe (liens hypermedia dans les réponses) et que presque personne ne l’implémente complètement.
En entretien
🎤 En entretien — l’exercice classique : « conçois l’API d’une todo-list ». Déroulez méthodiquement : ressources (
/todos,/todos/{id}), méthodes et codes (POST /todos→ 201 +Location,DELETE→ 204,PATCHpour cocher), pagination cursor surGET /todos, filtre?done=false, erreurs en problem+json, et finissez par « et si un autre service veut être notifié, webhook signé HMAC ». En cinq minutes vous avez montré toute la palette.
« Pourquoi la pagination par cursor plutôt que par offset ? » — Deux raisons : performance (l’offset force la DB à lire et jeter N lignes, le cursor fait un seek d’index) et stabilité (si des éléments sont insérés pendant le parcours, l’offset produit doublons ou trous, le cursor non). Contrepartie : pas de saut direct à la page 12.
« Comment gères-tu un POST rejoué à cause d’un timeout ? » — Header Idempotency-Key : le serveur stocke la clé et la réponse associée ; si la clé revient, il renvoie la réponse enregistrée sans réexécuter. Sans ça, un retry réseau peut débiter deux fois.
« Quand créer une v2 ? » — Uniquement sur breaking change : suppression/renommage de champ, changement de type ou de sémantique. Un ajout de champ ou d’endpoint est rétrocompatible. Et maintenir la v1 avec une date de fin de vie annoncée.
« Comment sécuriser un webhook ? » — Signature HMAC-SHA256 du corps avec un secret partagé, transmise en header, avec un timestamp inclus dans la signature pour empêcher le rejeu. Le récepteur vérifie en comparaison constante, répond 2xx vite, traite en async, déduplique par event id.
« 401 ou 403 ? » — 401 sans credentials valides (le client doit s’authentifier), 403 avec credentials valides mais droits insuffisants. Bonus : certains renvoient 404 au lieu de 403 pour ne pas révéler l’existence d’une ressource.
Pièges & idées reçues
- Le 200-erreur (voir callout plus haut) : le code de statut fait partie du contrat, pas le champ
success. - Verbes dans les URLs (
/createUser,/deleteOrder) : la méthode HTTP porte déjà le verbe ; doubler crée des incohérences. - Pagination sans borne : un
?limit=100000accepté tel quel, et votre DB tombe. Toujours plafonner côté serveur. - Breaking change silencieux : renommer un champ « parce que c’est plus propre » casse tous les clients. La compatibilité descendante est une contrainte permanente, pas une option.
- Webhook sans signature : n’importe qui peut poster un faux événement
payment_succeededsur votre endpoint. Signature obligatoire, toujours.
💡 Réflexe à montrer — face à n’importe quelle question d’API, penser « et le client qui retry ? ». Idempotence, déduplication,
Retry-After: montrer qu’on conçoit pour un réseau qui échoue, c’est le marqueur senior.
Pour aller plus loin
- RFC 9457 — Problem Details for HTTP APIs : le standard des erreurs structurées
- Zalando RESTful API Guidelines : le guide de référence d’une vraie boîte, très complet
- Stripe API Reference : l’API la plus imitée du monde — regarder pagination, idempotence, erreurs
- OpenAPI Specification et webhooks.fyi pour les patterns de webhooks
The essentials
An API is a contract between your server and clients you don’t control. Its quality isn’t measured by what it does, but by its predictability: a developer who has seen one endpoint should be able to guess the others. REST conventions exist precisely for that — following them gives you, for free, years of intuition accumulated by the ecosystem.
The ground rules fit in three lines:
- Resources are plural nouns, never verbs:
GET /users/42/orders, notGET /getOrdersOfUser?id=42. The verb is the HTTP method. - HTTP methods carry the semantics:
GETreads (no side effects),POSTcreates,PUTreplaces,PATCHpartially updates,DELETEremoves. - Status codes tell the truth: 2xx success, 4xx client error, 5xx server error.
401= not authenticated,403= authenticated but forbidden,404= not found,409= state conflict,422= syntactically valid payload that fails business rules.
⚠️ The lying 200 — the most widespread anti-pattern: replying
200 OKwith{"success": false, "error": "..."}in the body. Proxies cache the response, metrics believe everything is fine, clients must parse the body to know whether it worked, and automatic retries never trigger. The status code IS the error channel — use it.
How it works
Structured errors — An error must be machine-actionable AND human-readable. The standard is RFC 9457 (Problem Details) with the application/problem+json content type:
{
"type": "https://api.example.com/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Requested 5 units of item 4521, only 2 left.",
"instance": "/orders/abc123",
"available": 2
}
type identifies the error category (a stable, documentable URL), detail explains this specific case, and you can add business fields (available). A client can branch on type without parsing an English sentence.
Versioning — Two schools: version in the URL (/v1/users, visible, easy to route and cache) or in a header (Accept: application/vnd.api+json;version=2, more “pure” REST but invisible and painful to test in a browser). In practice, the URL wins almost everywhere (Stripe, GitHub). The real senior reflex: only version on breaking changes. Adding a field to a response is not breaking — clients must ignore unknown fields. Renaming or removing a field, changing a type: that is breaking.
Pagination — Never return an entire collection. Two strategies:
Offset (?page=3&limit=20) | Cursor (?after=xyz&limit=20) | |
|---|---|---|
| Implementation | Trivial (LIMIT/OFFSET) | Keyset on an indexed column |
| Deep pages | Slow (DB scans and discards) | Fast (direct index seek) |
| Inserts while paging | Duplicates or gaps | Stable |
| Jump to page N | Yes | No (sequential traversal) |
| Typical use | Back-office, small tables | Feeds, public APIs, large volumes |
The cursor encodes “where I am” (usually the last id/timestamp, opaque in base64): the query becomes WHERE created_at < :cursor ORDER BY created_at DESC LIMIT 20, served by the index at any depth.
// GET /todos?after=<cursor>&limit=20 — cursor pagination
app.get("/todos", async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100); // cap it!
const after = decodeCursor(req.query.after); // opaque { createdAt, id }
const rows = await db.todos.find({
where: after ? { createdAt: { lt: after.createdAt } } : {},
orderBy: { createdAt: "desc" },
take: limit + 1, // +1 to know if a page remains
});
const hasMore = rows.length > limit;
const items = rows.slice(0, limit);
res.json({
data: items,
next_cursor: hasMore ? encodeCursor(items.at(-1)) : null,
});
});
Filtering and sorting — via query params, simple conventions: ?status=open&sort=-created_at (the - for descending). Document which fields are filterable; blindly accepting everything means gifting full table scans to your users.
Idempotency — GET, PUT, DELETE are idempotent by definition (replaying = same state). POST is not: a client that times out and retries can create two orders. The fix: the Idempotency-Key header (popularized by Stripe). The client sends a unique key per operation; the server stores key → response and replays the recorded response if the key comes back. Retries become safe.
Rate limiting — reply 429 Too Many Requests with a Retry-After header (seconds or a date), plus the informative RateLimit-Limit / RateLimit-Remaining headers. A well-behaved client reads Retry-After and applies exponential backoff with jitter.
Webhooks — the API in reverse: you call the client when an event happens (payment confirmed, build finished).
Your API ──POST /hooks (event + signature)──▶ Client
│ │
│◀───────── got 2xx? otherwise retry ────────┘
│ expo backoff: 1min, 5min, 30min…
The rules of the game: sign the payload with HMAC-SHA256 using a shared secret, including a timestamp to block replay (the client checks signature + freshness); retry with exponential backoff until the client answers 2xx; on the client side, respond 2xx immediately and process asynchronously — and deduplicate by event.id, because retries guarantee at-least-once delivery, hence duplicates.
Documentation — an OpenAPI spec is not a luxury: it generates interactive docs (Swagger UI), typed clients, mocks and contract tests. Spec-first or code-first, doesn’t matter — what matters is that it is the source of truth.
Key concepts to master
- Resource vs action: when an operation doesn’t fit CRUD (
cancel an order), model a sub-resource or an action:POST /orders/42/cancel. Pragmatism > purity. - 401 vs 403: “I don’t know who you are” vs “I know who you are, and no”. Mixing them up in an interview is expensive.
- Unknown fields are ignored: this implicit contract is what makes additions non-breaking. A client that rejects unknown fields breaks itself.
- Response envelope:
{ "data": [...], "next_cursor": ... }rather than a bare array — a bare array can never gain metadata without a breaking change. - HATEOAS: know it exists (hypermedia links in responses) and that almost nobody implements it fully.
In an interview
🎤 In an interview — the classic exercise: “design the API for a todo-list”. Walk through it methodically: resources (
/todos,/todos/{id}), methods and codes (POST /todos→ 201 +Location,DELETE→ 204,PATCHto tick), cursor pagination onGET /todos, a?done=falsefilter, errors in problem+json, and finish with “and if another service wants notifications, HMAC-signed webhook”. In five minutes you’ve shown the whole palette.
“Why cursor pagination rather than offset?” — Two reasons: performance (offset forces the DB to read and discard N rows, a cursor does an index seek) and stability (if items are inserted while paging, offset produces duplicates or gaps, a cursor doesn’t). Trade-off: no direct jump to page 12.
“How do you handle a POST replayed because of a timeout?” — Idempotency-Key header: the server stores the key and the associated response; if the key comes back, it returns the recorded response without re-executing. Without it, a network retry can charge twice.
“When do you create a v2?” — Only on breaking changes: field removal/rename, type or semantics change. Adding a field or endpoint is backward compatible. And keep v1 alive with an announced end-of-life date.
“How do you secure a webhook?” — HMAC-SHA256 signature of the body with a shared secret, sent in a header, with a timestamp included in the signature to prevent replay. The receiver verifies in constant time, answers 2xx quickly, processes async, deduplicates by event id.
“401 or 403?” — 401 without valid credentials (the client must authenticate), 403 with valid credentials but insufficient rights. Bonus: some return 404 instead of 403 to avoid revealing a resource exists.
Pitfalls & misconceptions
- The 200-error (see callout above): the status code is part of the contract, not a
successfield. - Verbs in URLs (
/createUser,/deleteOrder): the HTTP method already carries the verb; doubling it breeds inconsistencies. - Unbounded pagination: accept
?limit=100000as-is and your DB goes down. Always cap server-side. - Silent breaking change: renaming a field “because it’s cleaner” breaks every client. Backward compatibility is a permanent constraint, not an option.
- Unsigned webhooks: anyone can post a fake
payment_succeededevent to your endpoint. Signature required, always.
💡 Reflex to show — facing any API question, think “and the client that retries?”. Idempotency, deduplication,
Retry-After: showing you design for a network that fails is the senior marker.
Going further
- RFC 9457 — Problem Details for HTTP APIs: the structured-error standard
- Zalando RESTful API Guidelines: a real company’s reference guide, very complete
- Stripe API Reference: the most imitated API in the world — study pagination, idempotency, errors
- OpenAPI Specification and webhooks.fyi for webhook patterns