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.

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

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.

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."
    }
  ]
}
  • 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 errors para detalhe de validação em nível de campo — você não está driblando.
  • Suporte de ferramental: OpenAPI 3.x suporta application/problem+json como 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").

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 — GET, PUT, PATCH bem-sucedido que retorna um body
  • 201 Created — POST bem-sucedido que criou um recurso; inclua um header Location apontando 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.

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

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

Paginaçã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.

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

Nomenclatura 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.

Dica de design: quando você está desenhando ou debugando uma resposta de API real, cole o JSON no JSON Formatter — ele vai formatar bem respostas minificadas, destacar estrutura, e te deixar ver inconsistências de nomenclatura de relance antes de virarem um SDK de cliente.

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.