Varje team jag har jobbat i uppfinner till slut sitt eget API-svarsformat. Det verkar harmlöst först — ett litet wrapper-objekt här, en custom felform där — och sex månader senare skriver du en fjärde version av din felparsning-middleware och argumenterar i code review om data.user eller data.result.user är den "rätta" sökvägen. Det finns ingen universell standard som löser allt det här, men det finns mönster som håller i produktion och antimönster som garanterat kommer tillbaka och biter dig. Här är vad jag faktiskt skulle sätta i ett design-dokument.

Konsistenta framgångssvar

Första frågan varje team debatterar: ska varje svar slås in i ett kuvert som {"status": "ok", "data": {...}}? Det ärliga svaret är — troligen inte som standard. Kuvert gjorde mer sens i början av 2000-talet när HTTP-statuskoder inte alltid var pålitliga genom proxies och mobilnätverk. Idag är ett platt svar som låter resursen tala för sig själv nästan alltid renare. Spara kuvertet för endpoints som genuint returnerar blandade payloads, som en bulk-operation som delvis lyckas.

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"
    }
  }
}

Inpackning är vettig när du behöver samlokalisera metadata som inte är del av själva resursen — pagineringskursors, request-ID:n för tracing, eller sammanfattningar av partiella fel i bulk-endpoints. För en enkel GET /orders/:id är ordern svaret. Tvinga inte klienter att skriva response.data.order.id när response.id fungerar fint. Om du vill ha en spec att referera till är JSON:API en åsiktsfull men väl genomtänkt standard som definierar exakt när och hur man använder kuvert — värd att läsa även om du inte adopterar den helt.

Felsvar — använd RFC 7807 Problem Details

Custom felformer är en av de vanligaste källorna till integrationssmärta. Varje API slutar med något lite olika — {"error": "..."}, {"message": "...", "code": 42}, {"errors": [...]} — och varje klient som konsumerar ditt API måste skriva skräddarsydd felparsnings-logik. IETF löste detta med RFC 7807 — Problem Details for HTTP APIs. Det är en lättviktsstandard som definierar en konsistent JSON-struktur för fel, med Content-Type application/problem+json. Adoptera den så blir ditt felformat något vilken utvecklare som helst kan läsa utan att greppa efter docs.

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."
    }
  ]
}
  • Förutsägbar parsning: Klienter vet alltid var de hittar det mänskligt läsbara meddelandet (detail), den maskinläsbara kategorin (type) och HTTP-statusen speglad i bodyn (status).
  • Utbyggbar från start: Specifikationen tillåter uttryckligen extra fält som errors för validering på fältnivå — du arbetar inte runt den.
  • Tooling-stöd: OpenAPI 3.x stödjer application/problem+json som response content type, så dina genererade docs och klient-SDK:er förstår formen nativt.
  • type-URI:n är ett dokument, inte bara en sträng: Peka den mot en riktig sida som förklarar felet, så har du precis ersatt ett supportärende med ett självbetjäningssvar.

HTTP-statuskoder + JSON-body tillsammans

Statuskoden och JSON-bodyn är inte redundanta — de spelar olika roller. Statuskoden berättar för HTTP-lagret (proxies, cachar, webbläsare, monitoreringsverktyg) vad som hände. JSON-bodyn berättar för ditt applikationslager. Båda måste vara korrekta. MDN:s HTTP-statusreferens är det snabbaste sättet att lösa debatter om vilken kod som passar. De som trippar upp team oftast är 400 vs 422 (båda är klientfel, men 422 betyder specifikt att syntaxen var giltig och servern förstod den — semantiken var fel), och 401 vs 403 (401 betyder "vem är du?", 403 betyder "jag vet vem du är — du får inte göra det här").

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 — lyckad GET, PUT, PATCH som returnerar en body
  • 201 Created — lyckad POST som skapade en resurs; inkludera en Location-header som pekar på den nya resursen
  • 204 No Content — lyckad DELETE eller action utan response body; ingen JSON behövs
  • 400 Bad Request — malformad request-syntax, servern kan inte ens parsa den
  • 401 Unauthorized — saknade eller ogiltiga autentiseringsuppgifter
  • 403 Forbidden — autentiserad men inte tillåten
  • 404 Not Found — resursen finns inte
  • 409 Conflict — tillståndskonflikt (t.ex. duplicerad order, optimistic lock failure)
  • 422 Unprocessable Entity — giltig syntax, misslyckades semantisk/affärsvalidering
  • 429 Too Many Requests — rate limit slagit; inkludera alltid en Retry-After-header
  • 500 Internal Server Error — något gick sönder på serversidan; läck aldrig stack traces i bodyn

Datum och tider — alltid ISO 8601

Unix-timestamps ser rena ut — bara ett tal. Men de är en fälla. Är 1710499200 sekunder eller millisekunder? (Båda är vanliga. JavaScripts Date.now() ger millisekunder, POSIX ger sekunder.) Vilken tidszon? De är oläsliga i loggar utan en konverterare. De kan inte representera datum före 1970 rent. Och de kommer overflow:a 32-bitars integers 2038 på system som inte har migrerat än. ISO 8601-strängar löser allt detta. Använd UTC och inkludera alltid tidszonsförskjutningen — en ensam 2026-03-15T11:42:00 utan ett efterföljande Z eller +00:00 är tvetydig och kommer så småningom orsaka en bugg i en klient som antar lokal tid.

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 utelämnade fält

Dessa två är inte samma sak, och att blanda ihop dem skapar subtila buggar som bara dyker upp i edge cases. Null betyder att fältet finns, servern vet om det, och dess nuvarande värde är "inget" — som en fulfilled_at-timestamp på en order som ännu inte skeppats. Att utelämna ett fält helt betyder att det inte är tillämpligt i det här sammanhanget — som ett return_tracking_number på en icke-returnerad order. Om en klient ser "fulfilled_at": null, vet den att fältet är del av resursens schema och är uttryckligen otilldelat. Om fältet saknas, ska klienten behandla det som utanför omfånget för detta svar — vilket spelar roll när du gör partiella uppdateringar med PATCH. Att skicka null betyder "nollställ fältet"; att utelämna det betyder "rör inte".

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

Paginering — cursor framför offset

Offset-paginering (?page=3&per_page=20) är intuitiv att implementera och lätt att förklara, men den går sönder tyst på levande data. Om en post infogas medan en klient paginerar — mellan sida 2 och sida 3 — hoppar de över ett objekt. Om en post raderas ser de en dubblett. För vilken dataset som helst som ändras ofta (orders, events, notifikationer) är cursor-baserad paginering den rätta standarden. Du ger klienten en ogenomskinlig cursor (vanligtvis en base64-kodad ID eller timestamp) som representerar deras position i resultatmängden. Nästa sida startar från exakt den punkten, oavsett insert eller delete. Offset- paginering är okej för admin-UI där dataseten är stabil och användarna genuint behöver hoppa till sida 47. Det är inte okej för någon mobil klient som gör infinite scroll.

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

Fältnamngivning — snake_case vs camelCase

Välj en konvention och upprätthåll den med en linter. Det faktiska valet spelar mindre roll än konsistensen. Med det sagt: om dina primära konsumenter är JavaScript/TypeScript-klienter integreras camelCase rent med destrukturering och object spread. Om dina primära konsumenter är Python- eller Ruby-backends känns snake_case naturligt. Om du betjänar båda är den pragmatiska lösningen att dokumentera konventionen och låta klienter använda ett transformationslager — JSON.parse med en reviver, ett Python-bibliotek som humps, eller en enda serialiseringskonfiguration i ditt ramverk. Vad du aldrig bör göra är att blanda konventioner i samma API — customerId bredvid order_total är ett tecken på att olika ingenjörer skrev olika endpoints utan att prata med varandra. Använd JSON Schema Generator för att dokumentera dina fältnamn konsistent över endpoints.

Designtips: När du designar eller debuggar ett verkligt API-svar, klistra in JSON:en i JSON Formatter — den pretty-printar minifierade svar, lyfter fram strukturen, och låter dig upptäcka namngivningsinkonsekvenser vid en snabb blick innan de tar sig in i ett klient-SDK.

Versionshantering

Två skolor: URL-versionering (/v1/orders, /v2/orders) och header-versionering (Accept: application/vnd.example.v2+json eller en custom API-Version: 2026-03-15- header). URL-versionering vinner i praktiken nästan varje gång. Den är synlig i loggar utan att parsa headers, den fungerar med varje HTTP-klient utan konfiguration, du kan testa den i en webbläsare, och du kan köra v1 och v2 sida vid sida i samma gateway med en enkel path-prefix-regel. Header-versionering är teoretiskt mer RESTfull enligt IANA media type- modellen, men det skapar osynlig komplexitet — en request som ser identisk ut i URL:en beter sig faktiskt olika beroende på en header de flesta utvecklare inte kollar först. Stripes datumbaserade versionering (Stripe-Version: 2024-06-20) är det bästa av två världar för stora plattformar, men det är ett annat problem än att välja ditt första versionsschema. Vad du än väljer, versionera från dag ett. Att eftermontera versionering på ett oversionerat API i produktion är smärtsamt och går sällan rent. Använd JSON Validator för att bekräfta att svaren från båda API-versionerna är strukturellt sunda under migreringstestning.

Avslutningsvis

Inget av detta är banbrytande — men det är poängen. Teamen som kämpar mest med API- design är inte de som gjorde tekniskt fel val. De är de som gjorde olika val i olika endpoints och aldrig skrev ner dem. Platta framgångssvar. RFC 7807-felbodies. ISO 8601- datum. Cursor-paginering på levande data. Null för "känt och tomt", utelämnat för "gäller inte". URL- versionering från dag ett. Dessa mönster är inte perfekta, men de är förutsägbara — och förutsägbarhet är det som gör ett API till ett nöje att integrera med snarare än ett pussel att reverse-engineera. Den formella JSON- specifikationen ligger på RFC 8259 om du någonsin behöver avgöra ett argument på spec-nivå. För allt ovanför det lagret är den bästa standarden den som ditt team faktiskt skriver ner och följer konsekvent.