Każdy zespół, w którym pracowałem, prędzej czy później wymyśla własny format odpowiedzi API. Na początku wydaje się to niewinne — mały obiekt-wrapper tu, niestandardowy kształt błędu tam — a pół roku później piszesz czwartą wersję middleware parsującego błędy i kłócisz się na code review, czy właściwa ścieżka to data.user, czy data.result.user. Nie ma jednego uniwersalnego standardu, który rozwiązuje to wszystko, ale są wzorce, które sprawdzają się na produkcji, i antywzorce, które absolutnie cię dopadną. Oto, co faktycznie wrzuciłbym do design doca.

Spójne odpowiedzi sukcesu

Pierwsze pytanie, o które każdy zespół się spiera: czy każda odpowiedź powinna być opakowana w envelope typu {"status": "ok", "data": {...}}? Szczera odpowiedź brzmi — prawdopodobnie nie domyślnie. Envelope'y miały więcej sensu na początku lat 2000, kiedy kody statusu HTTP nie zawsze były wiarygodne przez proxy i sieci mobilne. Dziś płaska odpowiedź, która pozwala zasobowi mówić za siebie, jest prawie zawsze czystsza. Zostaw envelope dla endpointów, które faktycznie zwracają mieszane payloady, jak operacja masowa, która częściowo się powiodła.

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

Opakowanie ma sens, kiedy trzeba zlokalizować obok siebie metadane, które nie są częścią samego zasobu — kursory paginacji, request ID do tracingu albo podsumowania częściowych porażek w bulk endpointach. Dla prostego GET /orders/:id zamówienie jest odpowiedzią. Nie zmuszaj klientów do pisania response.data.order.id, kiedy response.id działa równie dobrze. Jeśli chcesz specyfikacji do referencji, JSON:API to opiniowany, ale dobrze przemyślany standard, który definiuje dokładnie, kiedy i jak używać envelope'ów — wart przeczytania, nawet jeśli nie adoptujesz go w całości.

Odpowiedzi błędów — używaj RFC 7807 Problem Details

Niestandardowe kształty błędów to jedno z najczęstszych źródeł bólu w integracjach. Każde API kończy z czymś lekko innym — {"error": "..."}, {"message": "...", "code": 42}, {"errors": [...]} — a każdy klient, który konsumuje twoje API, musi pisać dedykowaną logikę parsowania błędów. IETF rozwiązało to RFC 7807 — Problem Details for HTTP APIs. To lekki standard, który definiuje spójną strukturę JSON dla błędów, z Content-Type application/problem+json. Zaadoptuj go, a twój format błędów stanie się czymś, co każdy dev potrafi przeczytać bez sięgania po dokumentację.

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."
    }
  ]
}
  • Przewidywalne parsowanie: Klienci zawsze wiedzą, gdzie znaleźć wiadomość czytelną dla człowieka (detail), kategorię czytelną dla maszyny (type) i status HTTP odzwierciedlony w body (status).
  • Rozszerzalne z założenia: Specyfikacja explicite pozwala na dodatkowe pola jak errors dla szczegółów walidacji per pole — nie obchodzisz standardu.
  • Wsparcie narzędziowe: OpenAPI 3.x wspiera application/problem+json jako response content type, więc twoje generowane docsy i klient SDK rozumieją kształt natywnie.
  • URI type to dokument, nie tylko string: Wskaż nim prawdziwą stronę wyjaśniającą błąd, a właśnie zamieniłeś ticket do supportu w samoobsługową odpowiedź.

Kody statusu HTTP + body JSON razem

Kod statusu i body JSON nie są nadmiarowe — pełnią różne role. Kod statusu mówi warstwie HTTP (proxy, cache'om, przeglądarkom, narzędziom monitoringu), co się stało. Body JSON mówi twojej warstwie aplikacji. Obie muszą być poprawne. Referencja kodów statusu HTTP w MDN to najszybszy sposób, żeby rozwiązać spór, który kod pasuje. Te, które najczęściej wywracają zespoły, to 400 vs 422 (oba to błędy klienta, ale 422 oznacza konkretnie, że składnia była poprawna i serwer zrozumiał — semantyka była zła) oraz 401 vs 403 (401 znaczy "kim jesteś?", 403 znaczy "wiem kim jesteś — nie możesz tego zrobić").

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 — pomyślny GET, PUT, PATCH, który zwraca body
  • 201 Created — pomyślny POST, który utworzył zasób; załącz nagłówek Location wskazujący na nowy zasób
  • 204 No Content — pomyślny DELETE albo akcja bez body odpowiedzi; JSON niepotrzebny
  • 400 Bad Request — zniekształcona składnia requestu, serwer nie może nawet sparsować
  • 401 Unauthorized — brak albo niepoprawne dane uwierzytelniające
  • 403 Forbidden — uwierzytelniony, ale bez uprawnień
  • 404 Not Found — zasób nie istnieje
  • 409 Conflict — konflikt stanu (np. duplikat zamówienia, failure optimistic locka)
  • 422 Unprocessable Entity — poprawna składnia, nie przeszła walidacja semantyczna/biznesowa
  • 429 Too Many Requests — trafiony rate limit; zawsze dołącz nagłówek Retry-After
  • 500 Internal Server Error — coś się wysypało po stronie serwera; nigdy nie puszczaj stack trace'ów w body

Daty i czasy — zawsze ISO 8601

Uniksowe timestampy wyglądają czysto — po prostu liczba. Ale to pułapka. Czy 1710499200 to sekundy, czy milisekundy? (Oba są popularne. JavaScriptowy Date.now() daje milisekundy, POSIX daje sekundy.) Jaka strefa czasowa? Są nieczytelne w logach bez konwertera. Nie reprezentują dat przed 1970 czysto. I przepełnią 32-bitowe inty w 2038 na systemach, które jeszcze nie zmigrowały. Stringi ISO 8601 rozwiązują to wszystko. Używaj UTC i zawsze dołączaj offset strefy czasowej — goły 2026-03-15T11:42:00 bez końcowego Z albo +00:00 jest niejednoznaczny i prędzej czy później spowoduje buga w kliencie, który zakłada czas lokalny.

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 pominięte pola

To dwie różne rzeczy i mieszanie ich tworzy subtelne bugi, które wypływają tylko w edge case'ach. Null znaczy, że pole istnieje, serwer o nim wie, a jego obecna wartość to "nic" — jak timestamp fulfilled_at na zamówieniu, które jeszcze nie zostało wysłane. Pominięcie pola całkowicie znaczy, że nie ma zastosowania w tym kontekście — jak return_tracking_number na niezwracanym zamówieniu. Jeśli klient widzi "fulfilled_at": null, wie, że pole jest częścią schemy tego zasobu i jest jawnie nieustawione. Jeśli pola nie ma, klient powinien traktować je jako spoza zakresu tej odpowiedzi — co ma znaczenie, kiedy robisz częściowe update'y przez PATCH. Wysłanie null znaczy "wyczyść to pole"; pominięcie znaczy "nie ruszaj".

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

Paginacja — kursor zamiast offsetu

Paginacja po offsecie (?page=3&per_page=20) jest intuicyjna w implementacji i łatwa do wyjaśnienia, ale wysypuje się po cichu na żywych danych. Jeśli rekord zostanie wstawiony, kiedy klient paginuje — między stroną 2 a stroną 3 — ominą element. Jeśli rekord zostanie usunięty, zobaczą duplikat. Dla każdego datasetu, który często się zmienia (zamówienia, zdarzenia, powiadomienia), paginacja kursorowa jest poprawnym domyślnym wyborem. Dajesz klientowi nieprzezroczysty kursor (typowo ID albo timestamp zakodowany w base64), który reprezentuje jego pozycję w zbiorze wyników. Następna strona zaczyna się od dokładnie tego punktu, niezależnie od wstawień albo usunięć. Paginacja po offsecie jest ok dla UI adminowych, gdzie dataset jest stabilny, a użytkownicy faktycznie potrzebują skoczyć do strony 47. Nie jest ok dla żadnego klienta mobilnego robiącego 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

Nazewnictwo pól — snake_case vs camelCase

Wybierz jedną konwencję i wyegzekwuj ją linterem. Sam wybór ma mniejsze znaczenie niż spójność. Niemniej: jeśli twoi główni konsumenci to klienci JavaScript/TypeScript, camelCase integruje się czysto z destrukturyzacją i object spread. Jeśli twoi główni konsumenci to backendy Pythona albo Ruby, snake_case czuje się naturalnie. Jeśli obsługujesz oba, pragmatyczne rozwiązanie to udokumentować konwencję i pozwolić klientom użyć warstwy transformacji — JSON.parse z reviverem, biblioteka humps w Pythonie albo pojedyncza konfiguracja serializacji w twoim frameworku. Czego nigdy nie powinieneś robić, to mieszać konwencji w tym samym API — customerId obok order_total to znak, że różni inżynierowie pisali różne endpointy bez rozmowy ze sobą. Użyj Generatora JSON Schema, żeby udokumentować nazwy swoich pól spójnie między endpointami.

Wskazówka projektowa: Kiedy projektujesz albo debuggujesz prawdziwą odpowiedź API, wklej JSON do JSON Formattera — sformatuje zminifikowane odpowiedzi, podświetli strukturę i pozwoli wychwycić niespójności nazewnictwa na pierwszy rzut oka, zanim trafią do klient SDK.

Wersjonowanie

Dwie szkoły: wersjonowanie URL (/v1/orders, /v2/orders) i wersjonowanie nagłówkiem (Accept: application/vnd.example.v2+json albo własny API-Version: 2026-03-15 header). Wersjonowanie URL wygrywa w praktyce niemal za każdym razem. Jest widoczne w logach bez parsowania nagłówków, działa z każdym klientem HTTP bez konfiguracji, możesz przetestować je w przeglądarce, i możesz puszczać v1 i v2 obok siebie w tym samym gateway'u prostą regułą path-prefix. Wersjonowanie nagłówkiem jest teoretycznie bardziej RESTowe wg modelu IANA media type, ale tworzy niewidzialną złożoność — request, który wygląda identycznie w URL-u, faktycznie zachowuje się inaczej w zależności od nagłówka, którego większość devów nie sprawdza od razu. Wersjonowanie po datach Stripe'a (Stripe-Version: 2024-06-20) to najlepsze z obu światów dla dużych platform, ale to inny problem niż wybór pierwszego schematu wersjonowania. Cokolwiek wybierzesz, wersjonuj od pierwszego dnia. Dokręcanie wersjonowania do niewersjonowanego API na produkcji jest bolesne i rzadko kończy się czysto. Użyj Walidatora JSON, żeby potwierdzić, że odpowiedzi z obu wersji API są strukturalnie poprawne podczas testów migracji.

Podsumowanie

Nic z tego nie jest przełomowe — ale to jest właśnie sedno. Zespoły, które najbardziej walczą z designem API, nie są tymi, które zrobiły technicznie błędne wybory. Są tymi, które zrobiły różne wybory w różnych endpointach i nigdy ich nie spisały. Płaskie odpowiedzi sukcesu. Body błędu RFC 7807. Daty ISO 8601. Paginacja kursorowa na żywych danych. Null dla "znane i puste", pominięte dla "nie dotyczy". Wersjonowanie URL od pierwszego dnia. Te wzorce nie są idealne, ale są przewidywalne — a przewidywalność to to, co sprawia, że API jest przyjemnością w integracji, a nie zagadką do rozszyfrowania. Formalna specyfikacja JSON żyje w RFC 8259, jeśli kiedyś będziesz musiał rozstrzygnąć spór na poziomie speca. Dla wszystkiego powyżej tej warstwy najlepszym standardem jest ten, który twój zespół faktycznie spisuje i konsekwentnie go trzyma.