An API is a contract. Once published and consumed by clients, every breaking change carries a real cost — coordination overhead, client-side hotfixes, and eroded trust. The decisions you make when designing an API are disproportionately expensive to reverse, which is why getting them right from the start matters more than almost any other engineering choice.Uma API é um contrato. Uma vez publicada e consumida por clientes, cada quebra de compatibilidade tem um custo real — sobrecarga de coordenação, correções emergenciais no lado do cliente e perda de confiança. As decisões tomadas no design de uma API são desproporcionalmente caras de reverter, e é por isso que acertá-las desde o início importa mais do que quase qualquer outra decisão de engenharia.
This article covers the decisions I apply when designing production REST APIs: resource modeling, versioning, error handling, pagination, security, and documentation. These are not theoretical principles — they are patterns that have survived contact with real traffic, real clients, and real incidents.Este artigo cobre as decisões que aplico ao projetar APIs REST em produção: modelagem de recursos, versionamento, tratamento de erros, paginação, segurança e documentação. Não são princípios teóricos — são padrões que sobreviveram ao contato com tráfego real, clientes reais e incidentes reais.
Resource ModelingModelagem de Recursos
REST is about resources, not actions. The URL identifies a resource; the HTTP method expresses what to do with it.REST é sobre recursos, não ações. A URL identifica um recurso; o método HTTP expressa o que fazer com ele.
# Good — nouns, plural, hierarchical
GET /orders # list orders
GET /orders/{id} # get one order
POST /orders # create order
PATCH /orders/{id} # update order
DELETE /orders/{id} # cancel order
GET /orders/{id}/items # list items in an order
# Bad — verbs in URLs, RPC-style
POST /getOrder
POST /createNewOrder
GET /deleteOrder?id=123Actions That Don't Fit CRUDAções Que Não Se Encaixam no CRUD
Some operations are genuinely not CRUD: confirming an order, archiving a document, resending an email. Two approaches:Algumas operações genuinamente não são CRUD: confirmar um pedido, arquivar um documento, reenviar um e-mail. Duas abordagens:
- Sub-resource action:
POST /orders/{id}/confirm— treats the action as a resource being created.Ação de sub-recurso:POST /orders/{id}/confirm— trata a ação como um recurso sendo criado. - State transition via PATCH:
PATCH /orders/{id}with body{"status": "confirmed"}— makes state explicit in the resource model.Transição de estado via PATCH:PATCH /orders/{id}com body{"status": "confirmed"}— torna o estado explícito no modelo de recurso.
Prefer state transitions via PATCH when the action naturally maps to a state change. Use sub-resource actions for complex commands with multiple side effects (e.g., POST /invoices/{id}/send).Prefira transições de estado via PATCH quando a ação mapeia naturalmente para uma mudança de estado. Use ações de sub-recurso para comandos complexos com múltiplos efeitos colaterais (ex.: POST /invoices/{id}/send).
HTTP Status CodesCódigos de Status HTTP
Use status codes semantically. Clients build logic on top of them — returning 200 for every response, including errors, forces clients to parse the body to know if the call succeeded.Use códigos de status de forma semântica. Clientes constroem lógica em cima deles — retornar 200 para toda resposta, incluindo erros, obriga os clientes a analisar o corpo para saber se a chamada foi bem-sucedida.
# Success
200 OK — GET, PATCH success with body
201 Created — POST success; include Location header
204 No Content — DELETE, PATCH with no response body
# Client errors
400 Bad Request — validation failure (include error details)
401 Unauthorized — missing or invalid authentication
403 Forbidden — authenticated but not authorized
404 Not Found — resource does not exist
409 Conflict — state conflict (e.g., duplicate, wrong status)
422 Unprocessable — semantically invalid (business rule violation)
429 Too Many Req. — rate limit exceeded
# Server errors
500 Internal Error — unexpected failure (never expose stack traces)
503 Unavailable — downstream dependency down; include Retry-AfterConsistent Error Response StructureEstrutura Consistente de Resposta de Erro
Every error response must follow the same structure. Clients must be able to parse errors programmatically, not by reading error messages written for humans.Toda resposta de erro deve seguir a mesma estrutura. Os clientes precisam conseguir processar erros programaticamente, não lendo mensagens de erro escritas para humanos.
// Standard error envelope
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "The request body contains invalid fields.",
"traceId": "abc123xyz",
"errors": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" },
{ "field": "amount", "code": "OUT_OF_RANGE", "message": "Must be between 0.01 and 999999" }
]
}The traceId field is non-negotiable in production. When a client reports an error, you need to correlate it to a specific request in your distributed tracing system within seconds — not minutes.O campo traceId é inegociável em produção. Quando um cliente reporta um erro, você precisa correlacioná-lo a uma requisição específica no seu sistema de rastreamento distribuído em segundos — não em minutos.
API VersioningVersionamento de API
APIs evolve. Without a versioning strategy, every change is a potential breaking change for every client. Three common approaches:APIs evoluem. Sem uma estratégia de versionamento, cada mudança é uma potencial quebra de compatibilidade para todos os clientes. Três abordagens comuns:
- URL path versioning:
/v1/orders,/v2/orders. Explicit, easy to route, easy to deprecate. The most widely used approach. Downside: the version becomes part of the resource URL, which is technically impure REST.Versionamento por path da URL:/v1/orders,/v2/orders. Explícito, fácil de rotear, fácil de deprecar. A abordagem mais amplamente usada. Desvantagem: a versão se torna parte da URL do recurso, o que tecnicamente não é REST puro. - Header versioning:
Accept: application/vnd.api+json;version=2. Cleaner URLs, but harder to test and share links.Versionamento por header:Accept: application/vnd.api+json;version=2. URLs mais limpas, mas mais difícil de testar e compartilhar links. - Query param:
/orders?version=2. Simplest for internal APIs, but pollutes query semantics.Query param:/orders?version=2. Mais simples para APIs internas, mas polui a semântica da query.
Recommendation: Use URL path versioning for public and partner APIs — it is explicit and cacheable. Use header versioning for internal service-to-service APIs where URL cleanliness matters and testing infrastructure is controlled.Recomendação: Use versionamento por path para APIs públicas e de parceiros — é explícito e cacheável. Use versionamento por header para APIs internas entre serviços onde a limpeza da URL importa e a infraestrutura de testes é controlada.
Deprecation PolicyPolítica de Deprecação
Never remove a version without notice. Include deprecation warnings in response headers starting at least 6 months before sunset:Nunca remova uma versão sem aviso prévio. Inclua avisos de deprecação nos headers de resposta com pelo menos 6 meses de antecedência:
Deprecation: Sun, 01 Jun 2025 00:00:00 GMT
Sunset: Mon, 01 Dec 2025 00:00:00 GMT
Link: <https://api.example.com/v2/orders>; rel="successor-version"PaginationPaginação
Never return unbounded collections. A GET /orders endpoint that returns all orders will eventually destroy your database and timeout the client.Nunca retorne coleções ilimitadas. Um endpoint GET /orders que retorna todos os pedidos eventualmente vai sobrecarregar o banco de dados e causar timeout no cliente.
Cursor-based Pagination (preferred)Paginação Baseada em Cursor (preferida)
// Request
GET /orders?cursor=eyJpZCI6MTIzfQ&limit=20
// Response
{
"data": [...],
"pagination": {
"limit": 20,
"nextCursor": "eyJpZCI6MTQzfQ", // null if last page
"hasMore": true
}
}Cursor-based pagination is stable under concurrent writes — a new order inserted on page 1 doesn't cause items to skip or duplicate across pages. Offset-based (?page=3&size=20) is simpler but breaks under high write rates.A paginação baseada em cursor é estável sob escritas concorrentes — um novo pedido inserido na página 1 não causa saltos ou duplicações de itens entre páginas. A paginação por offset (?page=3&size=20) é mais simples, mas falha com altas taxas de escrita.
Security: Beyond AuthenticationSegurança: Além da Autenticação
Rate LimitingLimitação de Taxa
Implement rate limiting at the API Gateway layer. Return 429 with a Retry-After header and expose limit metadata so clients can back off gracefully:Implemente limitação de taxa na camada do API Gateway. Retorne 429 com um header Retry-After e exponha metadados de limite para que os clientes possam recuar graciosamente:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711929600
Retry-After: 47Idempotency Keys for Non-Idempotent OperationsChaves de Idempotência para Operações Não Idempotentes
POST is not idempotent by default — a retry sends a duplicate request and creates a duplicate resource. For operations that can't be safely retried (charge a payment, send an email, create an order), use an idempotency key: a client-generated UUID sent as a header that ties the request to exactly one server-side effect.POST não é idempotente por padrão — um retry envia uma requisição duplicada e cria um recurso duplicado. Para operações que não podem ser repetidas com segurança (cobrar um pagamento, enviar um e-mail, criar um pedido), use uma chave de idempotência: um UUID gerado pelo cliente enviado como header que vincula a requisição a exatamente um efeito no servidor.
# Client sends a unique key per logical operation
POST /v1/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "customerId": "cust_123", "amountInCents": 5000, "currency": "BRL" }
# Server stores (idempotency_key, response) in a table for ~24h
# A second request with the same key returns the cached response
# without executing the payment againThis is critical for payment flows. Network timeouts between the client and server leave the client uncertain: did the charge go through? Without idempotency keys, a retry risks double-charging. With them, the server detects the duplicate and returns the original result. Stripe, PayPal, and every serious payment API require this pattern.Isso é crítico para fluxos de pagamento. Timeouts de rede entre o cliente e o servidor deixam o cliente incerto: a cobrança foi processada? Sem chaves de idempotência, um retry arrisca cobrar duas vezes. Com elas, o servidor detecta o duplicado e retorna o resultado original. Stripe, PayPal e toda API de pagamento séria exige esse padrão.
Input ValidationValidação de Entrada
Validate at the API boundary — never trust client input. Use Bean Validation in Spring Boot and validate all inputs before they touch business logic:Valide na fronteira da API — nunca confie em entradas do cliente. Use Bean Validation no Spring Boot e valide todas as entradas antes de chegarem à lógica de negócio:
public record PlaceOrderRequest(
@NotNull @Size(min=1, max=50)
String customerId,
@NotEmpty @Size(max=100)
List<@Valid OrderItemRequest> items,
@NotNull @DecimalMin("0.01") @DecimalMax("999999")
BigDecimal totalAmount
) {}Sensitive Data in ResponsesDados Sensíveis nas Respostas
Never expose internal identifiers, stack traces, database primary keys as sequential integers, or server implementation details in API responses. Sequential IDs allow enumeration attacks — use UUIDs or opaque tokens as public identifiers.Nunca exponha identificadores internos, stack traces, chaves primárias do banco de dados como inteiros sequenciais, ou detalhes de implementação do servidor nas respostas da API. IDs sequenciais permitem ataques de enumeração — use UUIDs ou tokens opacos como identificadores públicos.
Contract-First with OpenAPIContract-First com OpenAPI
Define your API contract in OpenAPI 3.x before writing any code. The spec becomes the single source of truth from which you generate server stubs, client SDKs, and interactive documentation.Defina o contrato da sua API em OpenAPI 3.x antes de escrever qualquer código. A especificação se torna a única fonte de verdade a partir da qual você gera stubs de servidor, SDKs de cliente e documentação interativa.
openapi: 3.1.0
info:
title: Order Service API
version: 1.0.0
paths:
/v1/orders:
post:
operationId: placeOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaceOrderRequest'
responses:
'201':
description: Order created
headers:
Location:
schema:
type: string
'400':
$ref: '#/components/responses/ValidationError'
'422':
$ref: '#/components/responses/BusinessRuleViolation'Practice: Use openapi-generator to generate Spring controller interfaces from the spec. Your implementation must satisfy the contract — not the other way around. This prevents the spec from drifting out of sync with the real API.Prática: Use openapi-generator para gerar interfaces de controller do Spring a partir da especificação. Sua implementação deve satisfazer o contrato — não o contrário. Isso evita que a especificação fique desatualizada em relação à API real.
Webhooks: Push-Based API EventsWebhooks: Eventos de API no Modelo Push
REST APIs are pull-based: the client asks, the server responds. Webhooks invert this — the server pushes events to a client-provided URL when something happens (payment authorized, order shipped, user signed up). This eliminates polling and delivers events in near-real-time without the client maintaining a persistent connection.APIs REST são baseadas em pull: o cliente pergunta, o servidor responde. Webhooks invertem isso — o servidor envia eventos para uma URL fornecida pelo cliente quando algo acontece (pagamento autorizado, pedido enviado, usuário cadastrado). Isso elimina o polling e entrega eventos em tempo quase real sem que o cliente mantenha uma conexão persistente.
Event Payload DesignDesign do Payload de Evento
Use a consistent envelope for every webhook event. Include a unique event ID, the event type, a timestamp, and the versioned payload. The event ID is critical for idempotency on the receiver side:Use um envelope consistente para todo evento de webhook. Inclua um ID único de evento, o tipo do evento, um timestamp e o payload versionado. O ID do evento é crítico para idempotência no lado receptor:
// Standard webhook event envelope
{
"id": "evt_01HZBKJ8T2PRXM7FQD4AKVY9GN", // unique — used as idempotency key
"type": "order.shipped",
"apiVersion": "2025-03-01",
"createdAt": "2025-04-15T14:22:00Z",
"data": {
"orderId": "ord_789xyz",
"customerId": "cust_123",
"trackingId": "BR123456789",
"shippedAt": "2025-04-15T14:20:00Z"
}
}Signature Verification (HMAC-SHA256)Verificação de Assinatura (HMAC-SHA256)
Webhooks are unauthenticated HTTP requests from the receiver's perspective — anyone who discovers your endpoint URL can send fake events. Sign every delivery with HMAC-SHA256 using a shared secret, and verify before processing:Webhooks são requisições HTTP não autenticadas da perspectiva do receptor — qualquer pessoa que descubra a URL do seu endpoint pode enviar eventos falsos. Assine cada entrega com HMAC-SHA256 usando um segredo compartilhado e verifique antes de processar:
# Sender — sign the raw request body
import hmac, hashlib
def sign_webhook(payload: bytes, secret: str) -> str:
return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
# Header added to every delivery: Webhook-Signature: sha256=abc123...
# Receiver — verify before trusting the payload
def verify_webhook(payload: bytes, header_sig: str, secret: str) -> bool:
expected = sign_webhook(payload, secret)
return hmac.compare_digest(expected, header_sig) # constant-time — never use ==Always use hmac.compare_digest (or its language equivalent) — never ==. Regular string equality short-circuits on the first differing byte, leaking timing information that attackers can exploit to forge valid signatures character by character.Sempre use hmac.compare_digest (ou equivalente na sua linguagem) — nunca ==. Igualdade de strings faz curto-circuito no primeiro byte diferente, vazando informações de timing que atacantes podem explorar para forjar assinaturas válidas caractere por caractere.
Retries and IdempotencyRetries e Idempotência
Your delivery system will retry on timeout or non-2xx response. The receiver must be idempotent — use the event id as the key in a processed-events table so duplicate deliveries are silently discarded, and always respond fast:Seu sistema de entrega fará retry em caso de timeout ou resposta não-2xx. O receptor deve ser idempotente — use o id do evento como chave numa tabela de eventos processados para que entregas duplicadas sejam silenciosamente descartadas, e sempre responda rapidamente:
# Retry schedule (exponential backoff)
Attempt 1: immediate
Attempt 2: 30 seconds
Attempt 3: 5 minutes
Attempt 4: 30 minutes
Attempt 5: 2 hours → dead letter after final failure
# Receiver — idempotency guard + async processing
def handle_webhook(event: dict):
if ProcessedEvent.exists(event["id"]):
return 200 # already handled — return OK so retries stop
queue.enqueue("process_event", event) # enqueue immediately
ProcessedEvent.create(event["id"])
return 200 # respond within 5s or sender considers it a timeoutRespond fast, process async. Never do slow work — DB queries, API calls, email sends — synchronously in a webhook handler. Accept the event, enqueue it (SQS, RabbitMQ, BullMQ), and return 200 immediately. If your handler takes more than ~5 seconds, the sender retries the delivery and you process the event twice.Responda rápido, processe de forma assíncrona. Nunca faça trabalho lento — queries de banco, chamadas de API, envios de e-mail — de forma síncrona num handler de webhook. Aceite o evento, coloque-o numa fila (SQS, RabbitMQ, BullMQ) e retorne 200 imediatamente. Se o handler demorar mais de ~5 segundos, o remetente fará retry e o evento será processado duas vezes.
Key TakeawaysConclusões Principais
- Model resources as nouns; use HTTP methods for semantics. Reserve sub-resource actions for complex commands.Modele recursos como substantivos; use métodos HTTP para semântica. Reserve ações de sub-recurso para comandos complexos.
- Use status codes correctly — never return
200for errors.Use códigos de status corretamente — nunca retorne200para erros. - Every error response must include a
traceIdand machine-readable error codes.Toda resposta de erro deve incluir umtraceIde códigos de erro legíveis por máquina. - Version your API from day one — URL path versioning for public APIs, header versioning for internal ones.Versione sua API desde o primeiro dia — versionamento por path para APIs públicas, por header para as internas.
- Use cursor-based pagination for collections that change frequently.Use paginação baseada em cursor para coleções que mudam com frequência.
- Define your contract in OpenAPI first; generate stubs from it to prevent drift.Defina seu contrato em OpenAPI primeiro; gere stubs a partir dele para evitar divergências.
- Implement rate limiting and input validation at the boundary — these protect your system regardless of what clients do.Implemente limitação de taxa e validação de entrada na fronteira — elas protegem seu sistema independentemente do que os clientes façam.
- Require idempotency keys on POST endpoints that trigger non-reversible side effects (payments, emails, order creation). They are the difference between a safe retry and a duplicate charge.Exija chaves de idempotência em endpoints POST que disparam efeitos colaterais irreversíveis (pagamentos, e-mails, criação de pedidos). Elas são a diferença entre um retry seguro e uma cobrança duplicada.
- Sign webhook deliveries with HMAC-SHA256 and always verify the signature before processing. Receivers must be idempotent using the event ID, and must respond within 5 seconds — process async.Assine entregas de webhooks com HMAC-SHA256 e sempre verifique a assinatura antes de processar. Receptores devem ser idempotentes usando o ID do evento e devem responder em até 5 segundos — processe de forma assíncrona.