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.
// ✅ 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ę.
// 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
errorsdla szczegółów walidacji per pole — nie obchodzisz standardu. - Wsparcie narzędziowe: OpenAPI 3.x wspiera
application/problem+jsonjako response content type, więc twoje generowane docsy i klient SDK rozumieją kształt natywnie. - URI
typeto 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ć").
// 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
Locationwskazują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.
// ✅ 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".
// 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 itPaginacja — 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.
// 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 === falseNazewnictwo 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.
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.