Ogni team con cui ho lavorato prima o poi inventa il suo formato per le risposte API. All'inizio sembra innocuo —
un piccolo oggetto wrapper qui, una forma di errore custom là — e poi sei mesi dopo stai scrivendo la quarta
versione del middleware che parsifica gli errori e discutendo in code review se data.user o
data.result.user sia il percorso "giusto". Non esiste uno standard universale che risolva tutto questo, ma
ci sono pattern che reggono in produzione e anti-pattern che ti si ritorceranno assolutamente contro.
Ecco cosa metterei davvero in un design doc.
Risposte di successo consistenti
La prima domanda che ogni team si pone: ogni risposta dovrebbe essere avvolta in un envelope tipo
{"status": "ok", "data": {...}}? La risposta onesta è — probabilmente no, di default. Gli envelope
avevano più senso nei primi anni 2000 quando i codici di status HTTP non erano sempre affidabili attraverso proxy e
reti mobili. Oggi una risposta piatta che lascia che la risorsa parli da sé è quasi sempre più pulita.
Riserva l'envelope per endpoint che restituiscono genuinamente payload misti, come un'operazione bulk che
riesce solo parzialmente.
// ✅ 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"
}
}
}L'avvolgimento ha senso quando devi co-locare metadata che non fanno parte della risorsa stessa —
cursori di paginazione, request ID per il tracing, o riassunti di fallimento parziale negli endpoint bulk. Per un semplice
GET /orders/:id, l'ordine È la risposta. Non costringere i client a scrivere
response.data.order.id quando response.id funziona benissimo. Se vuoi una spec a cui
fare riferimento, JSON:API è uno standard opinionato
ma ben ragionato che definisce esattamente quando e come usare gli envelope — vale la pena leggerlo anche se
non lo adotti in toto.
Risposte di errore — usa RFC 7807 Problem Details
Le forme di errore custom sono una delle cause più comuni di dolore nell'integrazione. Ogni API finisce con
qualcosa di leggermente diverso — {"error": "..."}, {"message": "...", "code": 42},
{"errors": [...]} — e ogni client che consuma la tua API deve scrivere logica di parsing degli errori
su misura. L'IETF ha risolto la faccenda con la
RFC 7807 — Problem Details for HTTP APIs.
È uno standard leggero che definisce una struttura JSON consistente per gli errori, con un
Content-Type di application/problem+json. Adottalo e il tuo formato di errore diventa
qualcosa che qualunque sviluppatore può leggere senza dover cercare la documentazione.
// 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."
}
]
}- Parsing prevedibile: I client sanno sempre dove trovare il messaggio leggibile dagli umani (
detail), la categoria machine-readable (type) e lo stato HTTP riflesso nel body (status). - Estensibile di design: La spec permette esplicitamente campi extra come
errorsper il dettaglio di validazione a livello di campo — non ci stai girando attorno. - Supporto da tool: OpenAPI 3.x supporta
application/problem+jsoncome content type di risposta, quindi la tua documentazione generata e gli SDK client capiscono la forma nativamente. - La URI
typeè un documento, non solo una stringa: Puntala a una pagina reale che spiega l'errore e hai appena sostituito un ticket di supporto con una risposta self-service.
Codici di status HTTP + body JSON insieme
Il codice di status e il body JSON non sono ridondanti — giocano ruoli diversi. Il codice di status dice al livello HTTP (proxy, cache, browser, strumenti di monitoring) cosa è successo. Il body JSON dice alla tua application layer. Entrambi devono essere corretti. Il riferimento agli status HTTP di MDN è il modo più veloce per risolvere dibattiti su quale codice usare. Quelli che confondono i team più spesso sono 400 vs 422 (entrambi sono errori del client, ma 422 significa specificamente che la sintassi era valida e il server l'ha compresa — la semantica era sbagliata), e 401 vs 403 (401 significa "chi sei?", 403 significa "so chi sei — non puoi farlo").
// 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 — GET, PUT, PATCH andati a buon fine che restituiscono un body
- 201 Created — POST andato a buon fine che ha creato una risorsa; includi un header
Locationche punta alla nuova risorsa - 204 No Content — DELETE o azione andata a buon fine senza body di risposta; nessun JSON necessario
- 400 Bad Request — sintassi della richiesta malformata, il server non riesce nemmeno a parsificarla
- 401 Unauthorized — credenziali di autenticazione mancanti o non valide
- 403 Forbidden — autenticato ma non autorizzato
- 404 Not Found — la risorsa non esiste
- 409 Conflict — conflitto di stato (es. ordine duplicato, fallimento di lock ottimistico)
- 422 Unprocessable Entity — sintassi valida, validazione semantica/di business fallita
- 429 Too Many Requests — rate limit raggiunto; includi sempre un header
Retry-After - 500 Internal Server Error — qualcosa si è rotto lato server; non far mai trapelare stack trace nel body
Date e orari — sempre ISO 8601
I timestamp Unix sembrano puliti — solo un numero. Ma sono una trappola. 1710499200 è in secondi
o millisecondi? (Entrambi sono comuni. Date.now() di JavaScript dà millisecondi, POSIX dà
secondi.) Quale fuso orario? Sono illeggibili nei log senza un convertitore. Non possono rappresentare date prima del
1970 in modo pulito. E andranno in overflow sugli interi a 32 bit nel 2038 nei sistemi che non hanno ancora migrato.
Le stringhe ISO 8601 risolvono
tutto questo. Usa UTC e includi sempre l'offset di fuso orario — un nudo 2026-03-15T11:42:00
senza una Z finale o un +00:00 è ambiguo e prima o poi causerà un bug in
un client che assume l'ora locale.
// ✅ 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 campi omessi
Questi due non sono la stessa cosa e confonderli crea bug subdoli che emergono solo in edge case.
Null significa che il campo esiste, il server lo conosce e il suo valore attuale è "niente" —
come un timestamp fulfilled_at su un ordine che non è ancora stato spedito.
Omettere un campo del tutto significa che non si applica in questo contesto — come un
return_tracking_number su un ordine non restituito. Se un client vede "fulfilled_at": null,
sa che il campo fa parte dello schema di questa risorsa ed è esplicitamente non impostato. Se il campo è assente, il
client dovrebbe trattarlo come fuori scope per questa risposta — il che conta quando stai facendo update parziali
con PATCH. Inviare null significa "azzera questo campo"; ometterlo significa "non toccarlo".
// 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 itPaginazione — cursor sopra offset
La paginazione offset (?page=3&per_page=20) è intuitiva da implementare e facile da spiegare,
ma si rompe silenziosamente su dati vivi. Se un record viene inserito mentre un client sta paginando — tra pagina 2
e pagina 3 — salteranno un elemento. Se un record viene cancellato, vedranno un duplicato. Per qualsiasi dataset che
cambia di frequente (ordini, eventi, notifiche), la paginazione cursor-based è il default corretto. Dai
al client un cursore opaco (tipicamente un ID o timestamp codificati in base64) che rappresenta la sua posizione nel
result set. La pagina successiva parte esattamente da quel punto, indipendentemente da inserimenti o cancellazioni. La paginazione
offset va bene per UI admin dove il dataset è stabile e gli utenti hanno genuinamente bisogno di saltare alla pagina 47.
Non va bene per nessun client mobile che fa 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 === falseDenominazione dei campi — snake_case vs camelCase
Scegli una convenzione e applicala con un linter. La scelta concreta conta meno della
consistenza. Detto questo: se i tuoi consumatori principali sono client JavaScript/TypeScript,
camelCase si integra in modo pulito con destructuring e object spread.
Se i tuoi consumatori principali sono backend Python o Ruby, snake_case sembra naturale.
Se servi entrambi, la soluzione pragmatica è documentare la convenzione e lasciare che i client usino un
layer di trasformazione — JSON.parse
con un reviver, una libreria Python humps, o una singola config di serializzazione nel tuo framework.
Quello che non dovresti mai fare è mescolare convenzioni nella stessa API — customerId accanto a
order_total è segno che diversi ingegneri hanno scritto diversi endpoint senza parlarsi
tra loro. Usa il Generatore JSON Schema per documentare i nomi dei campi
in modo consistente tra gli endpoint.
Versioning
Due scuole: versioning via URL (/v1/orders, /v2/orders) e versioning via header
(Accept: application/vnd.example.v2+json o un header custom API-Version: 2026-03-15).
Il versioning via URL vince in pratica quasi ogni volta. È visibile nei log senza parsare header,
funziona con ogni client HTTP senza configurazione, puoi testarlo in un browser e puoi far girare v1
e v2 fianco a fianco nello stesso gateway con una semplice regola di path-prefix. Il versioning via header è teoricamente
più RESTful per il modello dei
media type IANA,
ma crea complessità invisibile — una richiesta che sembra identica nell'URL in realtà si
comporta diversamente a seconda di un header che la maggior parte degli sviluppatori non controlla subito.
Il versioning basato su data di Stripe (Stripe-Version: 2024-06-20) è il meglio dei due mondi per
grandi piattaforme, ma è un problema diverso dallo scegliere il tuo primo schema di versioning.
Qualunque cosa scegli, versiona dal giorno uno. Retrofittare il versioning su una API non versionata in produzione
è doloroso e raramente va liscio. Usa il JSON Validator per confermare che le
risposte di entrambe le versioni dell'API siano strutturalmente sane durante i test di migrazione.
Tiriamo le somme
Niente di tutto questo è rivoluzionario — ma è proprio questo il punto. I team che faticano di più con il design delle API non sono quelli che hanno fatto scelte tecnicamente sbagliate. Sono quelli che hanno fatto scelte diverse in endpoint diversi e non le hanno mai scritte. Risposte di successo piatte. Body di errore RFC 7807. Date ISO 8601. Paginazione cursor su dati vivi. Null per "noto e vuoto", omesso per "non applicabile". Versioning via URL dal giorno uno. Questi pattern non sono perfetti, ma sono prevedibili — e la prevedibilità è ciò che rende un'API un piacere da integrare piuttosto che un puzzle da decodificare. La specifica formale JSON vive in RFC 8259 se mai ti serve per chiudere una discussione a livello di spec. Per tutto ciò che sta sopra quel livello, il miglior standard è quello che il tuo team effettivamente mette per iscritto e segue con consistenza.