Todo time com que trabalhei acabou inventando seu próprio formato de resposta de API. Parece inofensivo no começo —
um objeto envelope aqui, um formato de erro custom ali — e seis meses depois você está escrevendo a quarta
versão do seu middleware de parse de erro e discutindo em code review se data.user ou
data.result.user é o caminho "correto". Não tem nenhum padrão universal que resolva tudo isso, mas
tem padrões que se seguram em produção e anti-padrões que absolutamente vão te morder.
Aqui vai o que eu realmente colocaria num design doc.
Respostas de sucesso consistentes
A primeira pergunta que todo time debate: toda resposta deveria ser embrulhada num envelope tipo
{"status": "ok", "data": {...}}? A resposta honesta é — provavelmente não por padrão. Envelopes
faziam mais sentido no começo dos anos 2000 quando códigos de status HTTP nem sempre eram confiáveis em proxies e
redes móveis. Hoje, uma resposta flat que deixa o recurso falar por si mesmo é quase sempre mais limpa.
Reserve o envelope para endpoints que genuinamente retornam payloads mistos, tipo uma operação em lote que
tem sucesso parcial.
// ✅ 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"
}
}
}Embrulhar faz sentido quando você precisa juntar metadados que não são parte do recurso em si —
cursores de paginação, IDs de requisição para tracing, ou resumos de falha parcial em endpoints em lote. Para um simples
GET /orders/:id, a ordem é a resposta. Não faça os clientes escreverem
response.data.order.id quando response.id funciona perfeitamente. Se você quer uma spec
para referenciar, JSON:API é um padrão opinativo
mas bem pensado que define exatamente quando e como usar envelopes — vale a leitura mesmo que você
não adote por inteiro.
Respostas de erro — use RFC 7807 Problem Details
Formatos de erro custom são uma das fontes mais comuns de dor de integração. Toda API acaba com
algo ligeiramente diferente — {"error": "..."}, {"message": "...", "code": 42},
{"errors": [...]} — e todo cliente que consome sua API tem que escrever uma lógica de parse de erro
sob medida. A IETF resolveu isso com a
RFC 7807 — Problem Details for HTTP APIs.
É um padrão leve que define uma estrutura JSON consistente para erros, com um
Content-Type de application/problem+json. Adote e seu formato de erro vira
algo que qualquer dev consegue ler sem precisar recorrer à documentação.
// 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 previsível: Clientes sempre sabem onde achar a mensagem legível por humano (
detail), a categoria legível por máquina (type), e o status HTTP espelhado no body (status). - Extensível por design: A spec permite explicitamente campos extras como
errorspara detalhe de validação em nível de campo — você não está driblando. - Suporte de ferramental: OpenAPI 3.x suporta
application/problem+jsoncomo tipo de conteúdo de resposta, então sua doc gerada e SDKs de cliente entendem o formato nativamente. - A URI
typeé um documento, não só uma string: aponte ela para uma página real explicando o erro, e você acabou de substituir um ticket de suporte por uma resposta self-service.
Códigos de status HTTP + body JSON juntos
O código de status e o body JSON não são redundantes — eles têm papéis diferentes. O código de status conta para a camada HTTP (proxies, caches, navegadores, ferramentas de monitoramento) o que aconteceu. O body JSON conta para sua camada de aplicação. Os dois precisam estar corretos. A referência de status HTTP da MDN é o jeito mais rápido de resolver debates sobre qual código se encaixa. Os que mais confundem times são 400 vs 422 (ambos são erros de cliente, mas 422 especificamente significa que a sintaxe era válida e o servidor entendeu — a semântica estava errada), e 401 vs 403 (401 significa "quem é você?", 403 significa "eu sei quem você é — você não pode fazer isso").
// 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 bem-sucedido que retorna um body
- 201 Created — POST bem-sucedido que criou um recurso; inclua um header
Locationapontando para o novo recurso - 204 No Content — DELETE ou ação bem-sucedida sem body de resposta; sem JSON necessário
- 400 Bad Request — sintaxe de requisição malformada, o servidor não consegue nem parsear
- 401 Unauthorized — credenciais de autenticação ausentes ou inválidas
- 403 Forbidden — autenticado mas sem permissão
- 404 Not Found — recurso não existe
- 409 Conflict — conflito de estado (ex: pedido duplicado, falha de lock otimista)
- 422 Unprocessable Entity — sintaxe válida, falha em validação semântica/de negócio
- 429 Too Many Requests — rate limit atingido; sempre inclua um header
Retry-After - 500 Internal Server Error — algo quebrou no lado do servidor; nunca vaze stack traces no body
Datas e horas — sempre ISO 8601
Timestamps Unix parecem limpos — só um número. Mas são uma armadilha. 1710499200 é segundos
ou milissegundos? (Os dois são comuns. O Date.now() do JavaScript dá milissegundos, POSIX dá
segundos.) Qual fuso horário? Eles são ilegíveis em logs sem um conversor. Não conseguem representar datas antes
de 1970 de forma limpa. E vão estourar inteiros de 32 bits em 2038 em sistemas que ainda não migraram.
Strings ISO 8601 resolvem
tudo isso. Use UTC e sempre inclua o offset de fuso — um 2026-03-15T11:42:00 pelado
sem Z ou +00:00 no final é ambíguo e vai eventualmente causar um bug em
um cliente que assume hora local.
// ✅ 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 campos omitidos
Esses dois não são a mesma coisa e confundir gera bugs sutis que só aparecem em casos de borda.
Null significa que o campo existe, o servidor sabe dele, e seu valor atual é "nada" —
tipo um timestamp fulfilled_at numa order que ainda não despachou.
Omitir um campo por completo significa que não se aplica neste contexto — tipo um
return_tracking_number numa ordem que não foi devolvida. Se um cliente vê "fulfilled_at": null,
ele sabe que o campo faz parte do schema desse recurso e está explicitamente não definido. Se o campo estiver ausente, o
cliente deveria tratar como fora do escopo dessa resposta — o que importa quando você está fazendo atualizações
parciais com PATCH. Enviar null significa "limpe esse campo"; omitir significa "não mexe".
// 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 itPaginação — cursor em vez de offset
Paginação por offset (?page=3&per_page=20) é intuitiva de implementar e fácil de explicar,
mas quebra silenciosamente em dados ao vivo. Se um registro é inserido enquanto um cliente está paginando — entre a página 2
e a página 3 — eles vão pular um item. Se um registro é deletado, vão ver uma duplicata. Para qualquer dataset que
muda frequentemente (pedidos, eventos, notificações), paginação baseada em cursor é o padrão correto. Você dá
ao cliente um cursor opaco (tipicamente um ID ou timestamp codificado em base64) que representa a posição dele
no conjunto de resultados. A próxima página começa desse ponto exato, independente de inserções ou deleções. Paginação
por offset está bem para admin UIs onde o dataset é estável e usuários genuinamente precisam pular para a página 47.
Não está bem para nenhum cliente mobile fazendo scroll infinito.
// 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 === falseNomenclatura de campos — snake_case vs camelCase
Escolha uma convenção e imponha com um linter. A escolha em si importa menos do que a
consistência. Dito isso: se seus consumidores principais são clientes JavaScript/TypeScript,
camelCase se integra bem com desestruturação e object spread.
Se seus consumidores principais são backends Python ou Ruby, snake_case parece natural.
Se você serve ambos, a solução pragmática é documentar a convenção e deixar os clientes usarem uma
camada de transformação — JSON.parse
com um reviver, uma biblioteca humps em Python, ou uma única config de serialização no seu framework.
O que você nunca deve fazer é misturar convenções na mesma API — customerId ao lado de
order_total é sinal de que engenheiros diferentes escreveram endpoints diferentes sem conversar
uns com os outros. Use o JSON Schema Generator para documentar seus nomes
de campo de forma consistente entre endpoints.
Versionamento
Duas escolas: versionamento por URL (/v1/orders, /v2/orders) e versionamento por header
(Accept: application/vnd.example.v2+json ou um header custom API-Version: 2026-03-15).
Versionamento por URL vence na prática quase toda vez. É visível em logs sem parsear headers,
funciona com todo cliente HTTP sem configuração, você pode testar num navegador, e pode rodar v1
e v2 lado a lado no mesmo gateway com uma regra simples de prefixo de path. Versionamento por header é teoricamente
mais RESTful pela lógica de
media type da IANA,
mas cria complexidade invisível — uma requisição que parece idêntica na URL está na verdade
se comportando diferente dependendo de um header que a maioria dos devs não checa primeiro.
O versionamento baseado em data do Stripe (Stripe-Version: 2024-06-20) é o melhor dos dois mundos para
plataformas grandes, mas isso é um problema diferente de escolher seu primeiro esquema de versão.
Escolha o que escolher, versione desde o dia um. Retrofitar versionamento numa API sem versão em produção
é doloroso e raramente sai limpo. Use o JSON Validator para confirmar que
respostas das duas versões da API estão estruturalmente sólidas durante testes de migração.
Encerrando
Nada disso é revolucionário — mas esse é o ponto. Os times que mais sofrem com design de API não são os que fizeram escolhas tecnicamente erradas. São os que fizeram escolhas diferentes em endpoints diferentes e nunca anotaram. Respostas de sucesso flat. Bodies de erro RFC 7807. Datas ISO 8601. Paginação por cursor em dados ao vivo. Null para "conhecido e vazio", omitido para "não se aplica". Versionamento por URL desde o dia um. Esses padrões não são perfeitos, mas são previsíveis — e previsibilidade é o que torna uma API um prazer de integrar em vez de um quebra-cabeça de engenharia reversa. A especificação formal do JSON mora no RFC 8259 se você precisar resolver uma discussão em nível de spec. Para tudo acima dessa camada, o melhor padrão é o que seu time de fato anota e segue de forma consistente.