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.

json
// ✅ 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.

json
// 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 errors voor validatiedetail op veldniveau — je werkt er niet omheen.
  • Tool-ondersteuning: OpenAPI 3.x ondersteunt application/problem+json als 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").

json
// 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.

json
// ✅ 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".

json
// 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 it

Paginatie — 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.

json
// 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 === false

Veldnaamgeving — 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.

Design-tip: Wanneer je een echte API-response aan het ontwerpen of debuggen bent, plak de JSON in de JSON Formatter — hij print geminificeerde responses pretty, highlight de structuur en laat je naming-inconsistenties in één oogopslag spotten voor ze in een client-SDK terechtkomen.

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.