Elk team waarmee ik heb gewerkt verzint vroeg of laat zijn eigen API-responseformaat. In het begin lijkt het onschuldig —
een klein wrapper-object hier, een custom error-vorm daar — en zes maanden later schrijf je een vierde
versie van je error-parse-middleware en heb je een code review-discussie of data.user of
data.result.user het "juiste" pad is. Er is geen universele standaard die dit allemaal oplost, maar
er zijn patronen die het in productie volhouden en antipatronen die je absoluut komen bijten.
Dit is wat ik daadwerkelijk in een design doc zou zetten.
Consistente success-responses
De eerste vraag die elk team bespreekt: moet elke response in een envelope als
{"status": "ok", "data": {...}}? Het eerlijke antwoord is — waarschijnlijk niet default. Envelopes
hadden meer zin in de vroege jaren 2000 toen HTTP-status-codes niet altijd betrouwbaar waren over proxies en
mobiele netwerken. Vandaag is een platte response die de resource voor zichzelf laat spreken bijna altijd schoner.
Reserveer de envelope voor endpoints die echt gemengde payloads teruggeven, zoals een bulk-operatie die
gedeeltelijk slaagt.
// ✅ Good — flat, direct, the order IS the response
// GET /v1/orders/ord_9kZ2m
{
"id": "ord_9kZ2m",
"status": "fulfilled",
"customer_id": "cus_4xA1p",
"total_amount": 149.99,
"currency": "USD",
"created_at": "2026-03-15T11:42:00Z",
"line_items": [
{ "sku": "HDPHN-BLK-XM5", "quantity": 1, "unit_price": 149.99 }
]
}
// ❌ Avoid — unnecessary envelope adds a layer clients have to unwrap every time
{
"status": "success",
"code": 200,
"data": {
"order": {
"id": "ord_9kZ2m"
}
}
}Wrappen heeft zin als je metadata wilt co-localiseren die geen onderdeel is van de resource zelf —
paginatie-cursors, request-ID's voor tracing, of partial-failure-samenvattingen in bulk-endpoints. Voor een simpele
GET /orders/:id IS de order de response. Laat clients geen
response.data.order.id schrijven als response.id prima werkt. Als je een spec wilt
om naar te verwijzen, is JSON:API een
uitgesproken maar doordachte standaard die precies definieert wanneer en hoe je envelopes gebruikt — de moeite van het lezen waard, zelfs als
je hem niet volledig adopteert.
Error-responses — gebruik RFC 7807 Problem Details
Custom error-vormen zijn een van de meest voorkomende bronnen van integratie-pijn. Elke API eindigt met
iets lichtelijk anders — {"error": "..."}, {"message": "...", "code": 42},
{"errors": [...]} — en elke client die je API gebruikt moet maatwerk error-parse-
logica schrijven. De IETF heeft dit opgelost met
RFC 7807 — Problem Details for HTTP APIs.
Het is een lichtgewicht standaard die een consistente JSON-structuur voor errors definieert, met een
Content-Type van application/problem+json. Neem het over en je error-formaat wordt
iets dat elke developer kan lezen zonder docs te moeten raadplegen.
// POST /v1/orders — 422 Unprocessable Entity
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The order could not be created because one or more fields are invalid.",
"instance": "/v1/orders/requests/req_7bN3k",
"errors": [
{
"field": "line_items[0].quantity",
"message": "Quantity must be a positive integer."
},
{
"field": "shipping_address.postal_code",
"message": "Postal code is required for US shipments."
}
]
}- Voorspelbaar parsen: Clients weten altijd waar ze het door mensen leesbare bericht (
detail), de machine-leesbare categorie (type) en de HTTP-status gespiegeld in de body (status) kunnen vinden. - Uitbreidbaar qua design: De spec staat expliciet extra velden toe zoals
errorsvoor validatiedetail op veldniveau — je werkt er niet omheen. - Tool-ondersteuning: OpenAPI 3.x ondersteunt
application/problem+jsonals response-content-type, dus je gegenereerde docs en client-SDK's begrijpen de vorm native. - De
type-URI is een document, niet alleen een string: Wijs hem naar een echte pagina die de error uitlegt en je hebt zojuist een support-ticket vervangen door een self-service-antwoord.
HTTP-status-codes + JSON-body samen
De status-code en de JSON-body zijn niet redundant — ze spelen verschillende rollen. De status-code vertelt de HTTP-laag (proxies, caches, browsers, monitoring-tools) wat er is gebeurd. De JSON-body vertelt je application-laag. Beide moeten kloppen. MDN's HTTP-status-referentie is de snelste manier om discussies over welke code past op te lossen. De codes waar teams het vaakst over struikelen zijn 400 vs 422 (beide zijn client-fouten, maar 422 betekent specifiek dat de syntax geldig was en de server hem begreep — de semantiek klopte niet), en 401 vs 403 (401 betekent "wie ben je?", 403 betekent "ik weet wie je bent — je mag dit niet doen").
// 400 Bad Request — malformed JSON or missing required field at the HTTP level
{
"type": "https://api.example.com/problems/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "Request body is not valid JSON."
}
// 422 Unprocessable Entity — valid JSON, but business rules rejected it
{
"type": "https://api.example.com/problems/insufficient-inventory",
"title": "Insufficient Inventory",
"status": 422,
"detail": "HDPHN-BLK-XM5 has 0 units available; requested 2.",
"instance": "/v1/orders/requests/req_7bN3k"
}
// 404 Not Found — resource doesn't exist (or you don't want to reveal it does)
{
"type": "https://api.example.com/problems/not-found",
"title": "Order Not Found",
"status": 404,
"detail": "No order with ID ord_XXXXX exists in this account."
}- 200 OK — succesvolle GET, PUT, PATCH die een body teruggeeft
- 201 Created — succesvolle POST die een resource heeft gemaakt; voeg een
Location-header toe die naar de nieuwe resource wijst - 204 No Content — succesvolle DELETE of actie zonder response-body; geen JSON nodig
- 400 Bad Request — misvormde request-syntax, de server kan hem niet eens parsen
- 401 Unauthorized — ontbrekende of ongeldige authenticatie-credentials
- 403 Forbidden — geauthenticeerd maar niet toegestaan
- 404 Not Found — resource bestaat niet
- 409 Conflict — state-conflict (bijv. dubbele order, optimistic-lock-fout)
- 422 Unprocessable Entity — geldige syntax, semantische/business-validatie gefaald
- 429 Too Many Requests — rate-limit bereikt; voeg altijd een
Retry-After-header toe - 500 Internal Server Error — er is iets stuk aan de server-kant; lek nooit stack-traces in de body
Datums en tijden — altijd ISO 8601
Unix-timestamps zien er schoon uit — gewoon een getal. Maar ze zijn een valstrik. Is 1710499200 seconden
of milliseconden? (Beide komen veel voor. JavaScripts Date.now() geeft milliseconden, POSIX geeft
seconden.) Welke tijdzone? Ze zijn onleesbaar in logs zonder converter. Ze kunnen datums van voor
1970 niet netjes representeren. En ze zullen 32-bit integers overflowen in 2038 op systemen die nog niet zijn gemigreerd.
ISO 8601-strings lossen
dit allemaal op. Gebruik UTC en voeg altijd de timezone-offset toe — een kale 2026-03-15T11:42:00
zonder trailing Z of +00:00 is dubbelzinnig en zal uiteindelijk een bug veroorzaken in
een client die lokale tijd aanneemt.
// ✅ Good — unambiguous, human-readable, timezone-explicit
{
"created_at": "2026-03-15T11:42:00Z",
"updated_at": "2026-04-01T08:15:33Z",
"scheduled_delivery": "2026-03-18T00:00:00Z",
"expires_at": "2026-04-15T23:59:59Z"
}
// ❌ Avoid — ambiguous, unreadable, seconds vs ms confusion
{
"created_at": 1710499200,
"updated_at": 1743494133000,
"scheduled_delivery": "15/03/2026",
"expires_at": "April 15, 2026"
}Null vs weggelaten velden
Deze twee zijn niet hetzelfde en ze door elkaar halen creëert subtiele bugs die alleen in edge cases opduiken.
Null betekent dat het veld bestaat, dat de server ervan weet, en dat de huidige waarde "niets" is —
zoals een fulfilled_at-timestamp op een order die nog niet is verzonden.
Een veld helemaal weglaten betekent dat het niet van toepassing is in deze context — zoals een
return_tracking_number op een niet-geretourneerde order. Als een client "fulfilled_at": null ziet,
weet hij dat het veld deel is van het schema van deze resource en expliciet niet ingesteld. Als het veld ontbreekt, moet de
client het behandelen als buiten scope van deze response — wat ertoe doet als je partial
updates met PATCH doet. null sturen betekent "leegmaken dit veld"; weglaten betekent "niet aankomen".
// Order that exists but hasn't shipped yet
// fulfilled_at: null — we know about this field, it's just not set yet
// return_tracking_number: omitted — returns don't apply to this order
{
"id": "ord_9kZ2m",
"status": "processing",
"created_at": "2026-03-15T11:42:00Z",
"fulfilled_at": null,
"shipped_at": null,
"tracking_number": null,
"total_amount": 149.99
}
// PATCH /v1/orders/ord_9kZ2m — cancel the scheduled delivery
// Only include fields you want to change
{
"scheduled_delivery": null,
"status": "cancelled"
}
// "total_amount" is omitted — we're NOT zeroing it out, just not touching itPaginatie — cursor boven offset
Offset-paginatie (?page=3&per_page=20) is intuïtief te implementeren en makkelijk uit te leggen,
maar breekt stilletjes op live data. Als een record wordt ingevoegd terwijl een client pagineert — tussen pagina 2
en pagina 3 — slaan ze een item over. Als een record wordt verwijderd, zien ze een duplicaat. Voor elke dataset die
vaak verandert (orders, events, notifications), is cursor-gebaseerde paginatie de juiste default. Je geeft
de client een opaque cursor (doorgaans een base64-gecodeerde ID of timestamp) die zijn positie in de
result set representeert. De volgende pagina start vanaf exact dat punt, ongeacht inserts of deletes. Offset-
paginatie is prima voor admin-UI's waar de dataset stabiel is en users echt naar pagina 47 moeten kunnen springen.
Het is niet oké voor een mobiele client die infinite scroll doet.
// GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF85a1oybSJ9
{
"orders": [
{ "id": "ord_9kZ2m", "status": "fulfilled", "total_amount": 149.99, "created_at": "2026-03-15T11:42:00Z" },
{ "id": "ord_8jY1l", "status": "processing", "total_amount": 89.00, "created_at": "2026-03-14T09:10:00Z" }
],
"pagination": {
"next_cursor": "eyJpZCI6Im9yZF84alk1bCJ9",
"has_more": true,
"limit": 20
}
}
// When has_more is false, omit next_cursor entirely (or set to null)
// Clients: fetch next page with ?cursor=<next_cursor> until has_more === falseVeldnaamgeving — snake_case vs camelCase
Kies één conventie en dwing hem af met een linter. De feitelijke keuze doet er minder toe dan de
consistentie. Dat gezegd: als je primaire consumers JavaScript/TypeScript-clients zijn,
integreert camelCase netjes met destructuring en object-spread.
Als je primaire consumers Python- of Ruby-backends zijn, voelt snake_case natuurlijk.
Als je beide bedient, is de pragmatische oplossing om de conventie te documenteren en clients een
transformatielaag te laten gebruiken — JSON.parse
met een reviver, een Python-humps-library, of één serialisatie-config in je framework.
Wat je nooit moet doen is conventies mixen in dezelfde API — customerId naast
order_total is een teken dat verschillende engineers verschillende endpoints hebben geschreven zonder met
elkaar te praten. Gebruik de JSON Schema Generator om je veldnamen
consistent te documenteren over endpoints heen.
Versionering
Twee scholen: URL-versionering (/v1/orders, /v2/orders) en header-versionering
(Accept: application/vnd.example.v2+json of een custom API-Version: 2026-03-15-
header). URL-versionering wint in de praktijk bijna elke keer. Hij is zichtbaar in logs zonder headers te parsen,
werkt met elke HTTP-client zonder configuratie, je kunt hem in een browser testen, en je kunt v1
en v2 naast elkaar draaien in dezelfde gateway met een simpele path-prefix-regel. Header-versionering is in theorie
meer RESTful volgens het
IANA media type-
model, maar het creëert onzichtbare complexiteit — een request dat er in de URL identiek uitziet gedraagt zich
eigenlijk anders afhankelijk van een header die de meeste developers niet als eerste checken.
Stripe's datumgebaseerde versionering (Stripe-Version: 2024-06-20) is het beste van twee werelden voor
grote platforms, maar dat is een ander probleem dan je eerste versieschema kiezen.
Wat je ook kiest, versioneer vanaf dag één. Versionering retrofitten op een ongeversioneerde API in productie
is pijnlijk en gaat zelden netjes. Gebruik de JSON Validator om te bevestigen dat
responses van beide API-versies structureel gezond zijn tijdens migratietests.
Afronding
Niets hiervan is baanbrekend — maar dat is precies het punt. De teams die het meest worstelen met API- design zijn niet degenen die technisch verkeerde keuzes hebben gemaakt. Het zijn de teams die verschillende keuzes hebben gemaakt in verschillende endpoints en die nooit hebben opgeschreven. Platte success-responses. RFC 7807 error-bodies. ISO 8601- datums. Cursor-paginatie op live data. Null voor "bekend en leeg", weggelaten voor "niet van toepassing". URL- versionering vanaf dag één. Deze patronen zijn niet perfect, maar ze zijn voorspelbaar — en voorspelbaarheid is wat een API een plezier om mee te integreren maakt in plaats van een puzzel om te reverse-engineeren. De formele JSON- specificatie leeft in RFC 8259 als je ooit een spec-level-argument moet beslechten. Voor alles boven die laag is de beste standaard die je team daadwerkelijk opschrijft en consistent volgt.