Hvert team jeg har arbejdet i opfinder til sidst sit eget API-svarsformat. Det virker harmløst først —
et lille wrapper-objekt her, en custom fejlform der — og seks måneder senere skriver du en fjerde
version af din fejl-parsning-middleware og diskuterer i code review, om data.user eller
data.result.user er den "rigtige" sti. Der er ingen universel standard, der løser alt dette, men
der er mønstre der holder i produktion og antimønstre, der helt sikkert kommer tilbage og bider dig.
Her er hvad jeg faktisk ville sætte i et design-dokument.
Konsistente succes-svar
Første spørgsmål hvert team debatterer: skal hvert svar pakkes ind i en konvolut som
{"status": "ok", "data": {...}}? Det ærlige svar er — sandsynligvis ikke som standard. Konvolutter
gav mere mening i starten af 2000'erne, da HTTP-statuskoder ikke altid var pålidelige gennem proxyer og
mobilnetværk. I dag er et fladt svar, der lader ressourcen tale for sig selv, næsten altid renere.
Reserver konvolutten til endpoints, der genuint returnerer blandede payloads, som en bulk-operation der
delvist lykkes.
// ✅ 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"
}
}
}Indpakning giver mening, når du skal samle metadata, der ikke er en del af selve ressourcen —
paginerings-cursors, request-ID'er til tracing, eller delvis-fejl-sammenfatninger i bulk-endpoints. For et simpelt
GET /orders/:id er ordren svaret. Tving ikke klienter til at skrive
response.data.order.id, når response.id fungerer fint. Hvis du vil have en spec at
referere til, er JSON:API en meningsbaseret
men velgennemtænkt standard, der definerer præcis hvornår og hvordan man bruger konvolutter — værd at læse, selv om
du ikke adopterer den fuldt ud.
Fejlsvar — brug RFC 7807 Problem Details
Custom fejlformer er en af de mest almindelige kilder til integrationssmerter. Hvert API ender med
noget lidt forskelligt — {"error": "..."}, {"message": "...", "code": 42},
{"errors": [...]} — og hver klient, der konsumerer dit API, skal skrive skræddersyet
fejl-parsnings-logik. IETF løste dette med
RFC 7807 — Problem Details for HTTP APIs.
Det er en letvægtsstandard, der definerer en konsistent JSON-struktur for fejl, med
Content-Type application/problem+json. Adopter den, og dit fejlformat bliver
noget enhver udvikler kan læse uden at tage fat i docs.
// 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."
}
]
}- Forudsigelig parsning: Klienter ved altid, hvor de kan finde den menneskeligt læsbare besked (
detail), den maskinlæsbare kategori (type), og HTTP-statussen spejlet i body'en (status). - Udvidelig fra design: Specifikationen tillader eksplicit ekstra felter som
errorstil validering på feltniveau — du arbejder ikke uden om den. - Tooling-understøttelse: OpenAPI 3.x understøtter
application/problem+jsonsom response content type, så dine genererede docs og klient-SDK'er forstår formen nativt. type-URI'en er et dokument, ikke bare en streng: Peg den mod en rigtig side, der forklarer fejlen, og du har lige erstattet en supportsag med et selvbetjeningssvar.
HTTP-statuskoder + JSON-body sammen
Statuskoden og JSON-body'en er ikke overflødige — de spiller forskellige roller. Statuskoden fortæller HTTP-laget (proxyer, cacher, browsere, monitoreringsværktøjer) hvad der skete. JSON-body'en fortæller dit applikationslag. Begge skal være korrekte. MDN's HTTP-statusreference er den hurtigste måde at løse debatter om, hvilken kode der passer. Dem der oftest snubler team er 400 vs 422 (begge er klientfejl, men 422 betyder specifikt, at syntaksen var gyldig, og serveren forstod den — semantikken var forkert), og 401 vs 403 (401 betyder "hvem er du?", 403 betyder "jeg ved hvem du er — du må ikke gøre dette").
// 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 — vellykket GET, PUT, PATCH der returnerer en body
- 201 Created — vellykket POST der skabte en ressource; inkluder en
Location-header der peger på den nye ressource - 204 No Content — vellykket DELETE eller handling uden response body; ingen JSON nødvendig
- 400 Bad Request — malformet request-syntaks, serveren kan ikke engang parse den
- 401 Unauthorized — manglende eller ugyldige autentificeringsoplysninger
- 403 Forbidden — autentificeret men ikke tilladt
- 404 Not Found — ressourcen eksisterer ikke
- 409 Conflict — tilstandskonflikt (f.eks. dubleret ordre, optimistic lock-fejl)
- 422 Unprocessable Entity — gyldig syntaks, fejlede semantisk/forretningsvalidering
- 429 Too Many Requests — rate limit ramt; inkluder altid en
Retry-After-header - 500 Internal Server Error — noget gik i stykker på serversiden; lad aldrig stack traces slippe ud i body'en
Datoer og tider — altid ISO 8601
Unix-timestamps ser rene ud — bare et tal. Men de er en fælde. Er 1710499200 sekunder
eller millisekunder? (Begge er almindelige. JavaScripts Date.now() giver millisekunder, POSIX giver
sekunder.) Hvilken tidszone? De er ulæselige i logs uden en konverter. De kan ikke repræsentere datoer før
1970 pænt. Og de vil overflow 32-bit integers i 2038 på systemer, der endnu ikke er migreret.
ISO 8601-strenge løser
alt dette. Brug UTC og inkluder altid tidszoneforskydningen — et bart 2026-03-15T11:42:00
uden et efterfølgende Z eller +00:00 er tvetydigt og vil på et tidspunkt forårsage en bug i
en klient, der antager lokal tid.
// ✅ 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 udeladte felter
Disse to er ikke det samme, og at blande dem sammen skaber subtile bugs, der kun dukker op i edge cases.
Null betyder, at feltet eksisterer, serveren ved om det, og dets aktuelle værdi er "intet" —
som et fulfilled_at-timestamp på en ordre, der endnu ikke er sendt.
At udelade et felt helt betyder, at det ikke er relevant i denne kontekst — som et
return_tracking_number på en ikke-returneret ordre. Hvis en klient ser "fulfilled_at": null,
ved den, at feltet er en del af denne ressources skema og er eksplicit uset. Hvis feltet er fraværende, bør
klienten behandle det som uden for scope for dette svar — hvilket betyder noget, når du laver partielle
opdateringer med PATCH. At sende null betyder "nulstil feltet"; at udelade det betyder "rør ikke ved det".
// 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 itPaginering — cursor frem for offset
Offset-paginering (?page=3&per_page=20) er intuitiv at implementere og let at forklare,
men den går i stykker stille på levende data. Hvis en post indsættes, mens en klient paginerer — mellem side 2
og side 3 — springer de et element over. Hvis en post slettes, ser de en dublet. For ethvert datasæt, der
ændrer sig ofte (ordrer, events, notifikationer), er cursor-baseret paginering den korrekte standard. Du giver
klienten en uigennemsigtig cursor (typisk en base64-kodet ID eller timestamp), der repræsenterer deres position i
resultatsættet. Næste side starter fra præcis det punkt, uanset inserts eller deletes. Offset-
paginering er fin for admin-UI'er, hvor datasættet er stabilt, og brugere genuint skal hoppe til side 47.
Den er ikke fin for nogen mobilklient, der laver infinite scroll.
// 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 === falseFeltnavngivning — snake_case vs camelCase
Vælg én konvention og håndhæv den med en linter. Selve valget betyder mindre end
konsistensen. Når det er sagt: hvis dine primære forbrugere er JavaScript/TypeScript-klienter, integreres
camelCase rent med destructuring og object spread.
Hvis dine primære forbrugere er Python- eller Ruby-backends, føles snake_case naturligt.
Hvis du betjener begge, er den pragmatiske løsning at dokumentere konventionen og lade klienterne bruge et
transformationslag — JSON.parse
med en reviver, et Python humps-bibliotek, eller en enkelt serialiseringskonfiguration i dit framework.
Hvad du aldrig bør gøre, er at blande konventioner i samme API — customerId ved siden af
order_total er et tegn på, at forskellige ingeniører skrev forskellige endpoints uden at tale med
hinanden. Brug JSON Schema Generator til at dokumentere dine feltnavne
konsistent på tværs af endpoints.
Versionering
To skoler: URL-versionering (/v1/orders, /v2/orders) og header-versionering
(Accept: application/vnd.example.v2+json eller en custom API-Version: 2026-03-15-
header). URL-versionering vinder i praksis næsten hver gang. Den er synlig i logs uden at parse headers,
den virker med hver HTTP-klient uden konfiguration, du kan teste den i en browser, og du kan køre v1
og v2 side om side i samme gateway med en simpel path-prefix-regel. Header-versionering er teoretisk
mere RESTful ifølge
IANA media type-
modellen, men det skaber usynlig kompleksitet — en request der ser identisk ud i URL'en opfører sig faktisk
anderledes afhængigt af en header, de fleste udviklere ikke tjekker først.
Stripes datobaserede versionering (Stripe-Version: 2024-06-20) er det bedste af begge verdener for
store platforme, men det er et andet problem end at vælge dit første versionsskema.
Uanset hvad du vælger, version fra dag ét. At retrofit versionering på et ikke-versioneret API i produktion
er smertefuldt og går sjældent rent. Brug JSON Validator til at bekræfte, at
svar fra begge API-versioner er strukturelt korrekte under migrationstests.
Afrunding
Intet af dette er banebrydende — men det er pointen. Teams der kæmper mest med API- design er ikke dem, der traf teknisk forkerte valg. De er dem, der traf forskellige valg i forskellige endpoints og aldrig skrev dem ned. Flade succes-svar. RFC 7807-fejl-bodies. ISO 8601- datoer. Cursor-paginering på levende data. Null for "kendt og tom", udeladt for "gælder ikke". URL- versionering fra dag ét. Disse mønstre er ikke perfekte, men de er forudsigelige — og forudsigelighed er det, der gør et API til en fornøjelse at integrere med frem for et puslespil at reverse-engineere. Den formelle JSON- specifikation bor på RFC 8259, hvis du nogensinde skal afgøre et argument på spec-niveau. For alt over det lag er den bedste standard den, dit team faktisk skriver ned og følger konsekvent.