Blog

Contrato de API sem versão: evoluir o payload sem quebrar o cliente antigo

O campo foi renomeado numa terça-feira, o teste de integração passou, e na quinta o parceiro que integra desde 2021 abriu um chamado dizendo que o valor do pedido chegava zerado. A API nunca teve versão, ninguém sabia quem consumia o campo antigo, e reverter significava quebrar os três clientes que já tinham migrado. Este artigo mostra por que colocar um número na URL não é o mesmo que versionar um contrato, quais mudanças são realmente compatíveis e quais só parecem ser, como o cliente antigo quebra em campos que você adicionou e não em campos que removeu, como descobrir quem usa cada campo sem perguntar a ninguém, qual o roteiro de expandir e contrair aplicado a payload em vez de banco, e quais três alertas mostram que a remoção pode acontecer sem incidente.

2026-09-03 / Arquitetura / 17 min

01

Versionar a URL não é versionar o contrato

A primeira reação de quase todo time diante desse problema é criar um prefixo de versão e prometer que a partir de agora toda mudança entra em uma versão nova. A promessa dura até a terceira mudança. Manter duas versões completas de uma API significa manter dois caminhos de código, dois conjuntos de testes, duas rotinas de correção de bug e duas superfícies de segurança, e o custo disso é alto o suficiente para que a segunda versão sempre acabe sendo um repasse fino para a mesma implementação da primeira. No momento em que isso acontece, a versão na URL virou decoração: a mudança de comportamento vaza para os dois lados porque só existe um lado de verdade.

O contrato de uma API não é a rota, é o conjunto de suposições que o cliente faz sobre o que ele recebe e o que ele pode mandar. Um cliente que lê o campo de total supõe que ele existe, que é um número, que está em centavos, que não é nulo em pedido concluído, e que o valor cabe no tipo usado para desserializar. Nenhuma dessas suposições aparece na URL, e qualquer uma delas pode ser violada sem trocar de versão. A pergunta útil não é qual versão o cliente chama, é quais dessas quatro suposições a sua mudança está prestes a violar.

Mudança no payloadCompatível?O que realmente decide
Adicionar campo opcional na respostaQuase sempreSó quebra se o cliente valida esquema fechado ou desserializa em modo estrito
Adicionar campo obrigatório na requisiçãoNãoTodo cliente que ainda não manda o campo passa a receber erro de validação
Remover campo da respostaNãoQuebra na hora, mas de forma visível e rastreável
Renomear campo mantendo os doisSim, temporariamenteVira incompatível no dia da remoção, não no dia da renomeação
Trocar número por texto com o mesmo valorNãoDesserialização tipada falha mesmo com o valor idêntico
Adicionar valor novo a um campo enumeradoDependeQuebra todo cliente que mapeia o enumerado para um tipo fechado
Relaxar uma validação de entradaSim na entrada, não na saídaO dado mais permissivo que entra hoje sai amanhã para quem não espera por ele

As duas linhas que costumam surpreender são a do enumerado e a do relaxamento de validação. Adicionar o status de entrega parcial a um campo que antes só tinha três valores parece a mudança mais inofensiva possível, e derruba todo cliente escrito em linguagem com tipos fechados ou com um desvio condicional sem ramo padrão. Relaxar a validação de um campo de texto de cinquenta para quinhentos caracteres não quebra ninguém na entrada, e quebra o cliente que reserva cinquenta caracteres no banco dele no dia em que alguém usar os quinhentos. As duas têm a mesma assinatura: a mudança é aditiva do lado do servidor e restritiva do lado de quem consome.

02

O cliente antigo quebra no campo que você adicionou

A intuição de que adicionar é seguro e remover é perigoso está certa em média e errada nos casos que causam incidente. Adicionar um campo é seguro quando o cliente ignora o que não conhece, e essa é a configuração padrão da maioria das bibliotecas de desserialização. Mas basta um cliente ter ligado a validação estrita de esquema, ou usar uma biblioteca que falha diante de propriedade desconhecida por padrão, ou validar a resposta contra um esquema gerado a partir da especificação de dois anos atrás, para que a adição vire erro. O detalhe cruel é que esse erro acontece no cliente, aparece no log do cliente, e não gera nenhum sinal do lado do servidor: a sua taxa de erro continua em zero enquanto a integração do parceiro está caída.

// contract/compat.js
// A mesma resposta enviada para tres clientes com politicas de
// desserializacao diferentes. Adicionar um campo e seguro em um caso e
// fatal nos outros dois, e o servidor nao consegue distinguir.

const resposta = {
  id: 'ped_8812',
  total: 24990,          // centavos
  moeda: 'BRL',
  status: 'pago',
  descontoAplicado: 500, // campo novo, adicionado hoje
};

// Cliente A: ignora o que nao conhece. Continua funcionando.
const clienteTolerante = (json) => ({
  id: json.id,
  total: json.total,
});

// Cliente B: valida esquema fechado. Passa a rejeitar a resposta inteira
// por causa de um campo que ele nem queria ler.
const esquemaFechado = new Set(['id', 'total', 'moeda', 'status']);

const clienteEstrito = (json) => {
  for (const chave of Object.keys(json)) {
    if (!esquemaFechado.has(chave)) {
      throw new Error('propriedade desconhecida: ' + chave);
    }
  }
  return { id: json.id, total: json.total };
};

// Cliente C: mapeia o enumerado para um conjunto fechado. Sobrevive ao
// campo novo e morre no dia em que um status novo for adicionado.
const clienteEnumerado = (json) => {
  switch (json.status) {
    case 'pendente':
      return { pagavel: true };
    case 'pago':
      return { pagavel: false };
    case 'cancelado':
      return { pagavel: false };
    default:
      throw new Error('status desconhecido: ' + json.status);
  }
};

// A unica mudanca que os tres toleram e a que nao altera o conjunto de
// chaves nem o dominio de valores dos campos existentes. Qualquer outra
// precisa ser medida antes, nao anunciada depois.
export { clienteTolerante, clienteEstrito, clienteEnumerado, resposta };

A conclusão prática é que a compatibilidade não é uma propriedade da mudança, é uma propriedade do par formado pela mudança e pelo comportamento real dos clientes que existem hoje. Você não sabe qual dos três clientes acima está do outro lado, e não vai descobrir perguntando, porque quem responde a um formulário de integração é quem lembra de responder, não quem tem o código mais antigo em produção. A única fonte confiável é o tráfego, e é por isso que a primeira etapa de qualquer evolução de contrato é instrumentar antes de mudar.

03

Descobrir quem usa cada campo sem perguntar a ninguém

Do lado da requisição a medição é direta: cada campo que chega é observável, e registrar quais chaves o cliente enviou já responde quem depende do quê. Do lado da resposta o problema é mais difícil, porque o servidor manda tudo e não vê o que o cliente lê. Existem três formas práticas de recuperar essa informação, e a ordem de preferência é a mesma em quase todo sistema.

  1. Pedir ao cliente que declare o que quer. Um parâmetro de seleção de campos, uma consulta com projeção explícita ou um cabeçalho de perfil de resposta transformam a leitura em algo declarado. É a única forma que dá certeza, e é a que exige mudança do outro lado, então serve para clientes novos e não resolve o legado.
  2. Retirar o campo de uma fatia pequena do tráfego e observar quem reclama. É a forma mais barata de descobrir dependência real: remova o campo para um por cento das requisições de um cliente específico, mantenha por vinte e quatro horas e veja se a taxa de erro dele muda. Funciona bem quando você consegue segmentar por credencial e reverter em segundos.
  3. Correlacionar por comportamento observável. Se o cliente lê o total e o campo passa a vir errado, ele para de criar cobranças, ou passa a criar cobranças com valor diferente, ou aumenta a taxa de nova tentativa. Nem sempre existe um sinal desses, mas quando existe ele é o mais honesto, porque mede consequência e não intenção.
// contract/uso-de-campo.js
// Instrumentacao minima para responder "quem ainda depende deste campo".
// Registra por credencial, nao por rota, porque a decisao de remover e
// sempre sobre um cliente concreto e nao sobre um endpoint.

const janelaMs = 30 * 24 * 60 * 60 * 1000; // 30 dias
const uso = new Map(); // chave: clienteId + '|' + campo -> ultimoUsoMs

const chaveDe = (clienteId, campo) => clienteId + '|' + campo;

export const registrarCamposRecebidos = (clienteId, corpo) => {
  const agora = Date.now();
  for (const campo of Object.keys(corpo || {})) {
    uso.set(chaveDe(clienteId, campo), agora);
  }
};

// Para a resposta o servidor nao observa a leitura. O que ele observa e a
// declaracao: quando o cliente pede projecao, isso e leitura confirmada.
export const registrarCamposSolicitados = (clienteId, campos) => {
  const agora = Date.now();
  for (const campo of campos) {
    uso.set(chaveDe(clienteId, campo), agora);
  }
};

// Relatorio que decide a remocao: quais clientes tocaram o campo dentro da
// janela. Lista vazia nao prova ausencia de uso, prova ausencia de
// evidencia, e a diferenca entre as duas coisas e o que separa uma
// remocao planejada de um incidente.
export const clientesQueUsam = (campo, agora = Date.now()) => {
  const limite = agora - janelaMs;
  const encontrados = [];
  for (const [chave, ultimoUso] of uso) {
    const separador = chave.lastIndexOf('|');
    const clienteId = chave.slice(0, separador);
    const campoRegistrado = chave.slice(separador + 1);
    if (campoRegistrado === campo && ultimoUso >= limite) {
      encontrados.push({ clienteId, ultimoUso });
    }
  }
  return encontrados.sort((a, b) => b.ultimoUso - a.ultimoUso);
};

// Cobertura da medicao: sem isso o relatorio acima e enganoso, porque um
// cliente que integra uma vez por trimestre nao aparece na janela.
export const coberturaDaJanela = (clienteId, agora = Date.now()) => {
  const limite = agora - janelaMs;
  const prefixo = clienteId + '|';
  for (const [chave, ultimoUso] of uso) {
    if (chave.startsWith(prefixo) && ultimoUso >= limite) {
      return { observado: true };
    }
  }
  return { observado: false, motivo: 'cliente silencioso na janela' };
};

A função de cobertura é a parte que costuma faltar e é a que evita o erro mais caro dessa etapa. Um relatório que diz que nenhum cliente usou o campo nos últimos trinta dias parece autorização para remover, e não é: pode significar que ninguém usa, ou que o cliente que usa integra uma vez por trimestre no fechamento contábil. A janela de observação precisa ser maior que o maior período de chamada dos seus clientes, e para integração financeira isso raramente é menos de treze meses.

04

Expandir, migrar e contrair aplicado ao payload

O roteiro que resolve migração de esquema de banco sem janela de manutenção resolve evolução de contrato pelo mesmo motivo: em ambos existe um período em que dois formatos precisam coexistir e o escritor não controla o leitor. A diferença é que no banco o leitor é o seu próprio código e a contração leva semanas, enquanto na API o leitor é de outra empresa e a contração leva trimestres. A estrutura das quatro etapas é idêntica.

EXPANDIR  (semana 0)
  resposta: { valorTotal: 24990, total: 24990 }
  requisicao aceita: valorTotal OU total, ambos validos
  regra: se os dois vierem e divergirem, erro explicito 422
         (aceitar em silencio esconde o bug do cliente)

MIGRAR    (semana 0 ate a evidencia parar)
  clientes novos: documentacao mostra so valorTotal
  clientes antigos: cabecalho Deprecation + Sunset na resposta
  metrica por credencial: quem ainda toca 'total'

MEDIR     (janela >= maior periodo de chamada do cliente)
  relatorio semanal: clientes com uso de 'total' na janela
  contato individual, nao aviso em changelog
  criterio de saida: zero uso E cobertura confirmada

CONTRAIR  (so depois do criterio)
  remocao gradual: 1% -> 10% -> 50% -> 100% do trafego
  reversao em um comando ate os 100%
  campo removido da resposta e rejeitado na requisicao

O QUE NUNCA E ETAPA
  anunciar no changelog e remover no prazo anunciado
  sem medir uso: o prazo mede a sua paciencia, nao o risco

A regra do erro explícito quando os dois campos chegam divergentes é a parte contraintuitiva e a que evita o pior desfecho. A tentação é aceitar o campo novo e ignorar o antigo, porque isso mantém a requisição funcionando. O problema é que um cliente que manda os dois com valores diferentes tem um defeito, e aceitar em silêncio significa gravar o valor errado sem que ninguém descubra até a conciliação. Responder com erro de validação transforma um dado corrompido em um chamado de suporte, e chamado de suporte é infinitamente mais barato.

A remoção gradual por porcentagem de tráfego é o que diferencia contração de aposta. Remover para um por cento das requisições durante um dia e observar a taxa de erro por credencial expõe o cliente que a medição não pegou, e expõe com um por cento de dano em vez de cem. A regra de operação é que a reversão precisa ser um comando único e imediato até o momento em que o percentual chega a cem, porque é exatamente nessa faixa que o cliente silencioso aparece.

05

Cabeçalhos de descontinuação que servem para alguma coisa

Anunciar descontinuação no changelog tem taxa de leitura próxima de zero, porque quem mantém a integração raramente é quem assinou a lista de novidades. O sinal precisa viajar junto com a resposta que o cliente já está consumindo, e existem dois cabeçalhos padronizados para isso: Deprecation, que diz que o recurso está descontinuado, e Sunset, que diz a data em que ele deixa de existir. Ambos aceitam data em formato HTTP, e a combinação com um link para a documentação da migração é o mínimo que funciona.

// contract/descontinuacao.js
// Emite os sinais de descontinuacao junto da propria resposta e registra
// que o cliente recebeu o aviso. O registro e o que permite dizer, no dia
// da remocao, ha quanto tempo aquele cliente especifico esta avisado.

const avisosEntregues = new Map(); // clienteId -> { primeiro, ultimo, total }

export const aplicarDescontinuacao = ({
  res,
  clienteId,
  campo,
  sunsetISO,
  docUrl,
}) => {
  const sunset = new Date(sunsetISO);

  res.setHeader('Deprecation', 'true');
  res.setHeader('Sunset', sunset.toUTCString());
  res.setHeader('Link', '<' + docUrl + '>; rel="deprecation"; type="text/html"');
  res.setHeader(
    'Warning',
    '299 - "campo ' + campo + ' sera removido em ' + sunsetISO + '"',
  );

  const agora = Date.now();
  const registro = avisosEntregues.get(clienteId) || {
    primeiro: agora,
    ultimo: agora,
    total: 0,
  };
  registro.ultimo = agora;
  registro.total += 1;
  avisosEntregues.set(clienteId, registro);
};

// Criterio objetivo para autorizar a contracao de um cliente especifico:
// ele precisa ter recebido o aviso por tempo suficiente e ter parado de
// usar o campo. Um dos dois sozinho nao basta.
export const podeContrair = ({ clienteId, usosRecentes, diasMinimos = 90 }) => {
  const registro = avisosEntregues.get(clienteId);
  if (!registro) {
    return { pode: false, motivo: 'cliente nunca recebeu o aviso' };
  }

  const diasAvisado = (Date.now() - registro.primeiro) / 86400000;
  if (diasAvisado < diasMinimos) {
    return { pode: false, motivo: 'avisado ha ' + Math.floor(diasAvisado) + ' dias' };
  }
  if (usosRecentes > 0) {
    return { pode: false, motivo: usosRecentes + ' usos na janela' };
  }
  return { pode: true, diasAvisado: Math.floor(diasAvisado) };
};

A função que decide se pode contrair é onde a política vira código auditável. Ela exige duas condições independentes: o cliente foi avisado por tempo suficiente e o cliente parou de usar o campo. Exigir só a primeira é o modelo do prazo anunciado, que quebra quem não leu. Exigir só a segunda é o modelo da medição pura, que quebra quem tem sazonalidade. Juntas, as duas produzem uma decisão por cliente e não por endpoint, e é por cliente que o incidente acontece.

06

Três alertas que dizem que a remoção pode acontecer

A instrumentação da evolução de contrato falha de um jeito específico: as métricas ficam boas justamente porque o campo antigo continua sendo servido, e a ausência de erro é lida como ausência de dependência. Os três alertas abaixo medem coisas que mudam antes do incidente e não depois.

AlertaSinal medidoPor que ele antecipa o incidente
Uso de campo descontinuado por credencialContagem semanal por cliente, nunca agregadaO agregado cai para perto de zero enquanto um cliente grande continua em cem por cento
Taxa de erro do cliente durante a remoção gradualErro por credencial comparado à linha de base do próprio clientePega o cliente silencioso a um por cento de tráfego, quando reverter ainda é barato
Cobertura da janela de observaçãoClientes ativos que não apareceram na janelaDistingue quem parou de usar o campo de quem parou de chamar a API

O terceiro alerta é o que evita o erro que este artigo inteiro tenta prevenir. Um cliente que não aparece na janela de trinta dias não é um cliente que migrou, é um cliente sobre o qual você não tem informação. Separar esses dois grupos no relatório transforma a decisão de remover de um palpite baseado em silêncio em uma decisão baseada em evidência, e é a diferença entre encerrar uma migração e descobrir na segunda-feira do fechamento que o parceiro de 2021 nunca leu o changelog.

FAQ

Perguntas frequentes

Se toda mudança precisa desse processo, não é mais simples manter versões separadas mesmo?

Manter versões separadas parece mais simples porque adia o custo em vez de eliminá-lo, e o adiamento tem juros. Duas versões só são realmente independentes se tiverem código, testes e implantação próprios, e nesse caso toda correção de defeito e toda mudança de segurança precisa ser feita duas vezes, com risco de divergência silenciosa entre elas. O que quase sempre acontece na prática é a versão nova virar uma camada fina de tradução sobre a implementação da versão antiga, e aí você tem o pior dos dois mundos: o custo de manter duas superfícies públicas e nenhuma das garantias de isolamento que justificavam a separação. Há um caso legítimo para versão nova, e ele é específico: quando o modelo de domínio muda, não o formato. Se o recurso deixa de ser um pedido com itens e passa a ser um pedido com remessas que contêm itens, não existe transformação de campos que reconcilie os dois, e tentar coexistir produz um formato que nenhum dos dois lados entende bem. Para renomear campo, mudar tipo, adicionar restrição ou alterar valor padrão, expandir e contrair custa menos e entrega mais controle. A regra prática que uso é versionar quando a mudança tem nome de conceito e evoluir quando a mudança tem nome de campo.

Como lidar com o cliente que simplesmente não migra, mesmo depois de avisado por um ano?

Primeiro é preciso separar dois casos que parecem iguais e exigem tratamento oposto. O cliente que não migrou porque não viu o aviso se resolve com contato direto, e o teste é simples: mande uma mensagem para o contato técnico da credencial e veja se alguém responde em uma semana. O cliente que não migra porque não tem equipe para isso é um problema comercial, não técnico, e insistir tecnicamente só transfere a decisão para o dia do incidente. Para o segundo caso existem três saídas viáveis. A primeira é congelar aquela credencial em uma camada de compatibilidade explícita: uma tradução do formato antigo para o novo aplicada só àquele cliente, isolada do caminho principal, com custo de manutenção conhecido e revisado por trimestre. A segunda é degradação progressiva com data, do tipo em que o campo antigo passa a ser servido com latência adicional ou limite de taxa menor, o que cria pressão sem quebrar. A terceira é a remoção com data firme depois de comunicação formal, que é legítima quando o contrato comercial prevê e quando o custo de manter a compatibilidade excede o valor daquele cliente. O erro é não escolher: manter indefinidamente sem decisão explícita é como as APIs acumulam campos que ninguém entende e ninguém pode remover.

Especificação formal com validação automática de compatibilidade substitui a medição de uso?

Substitui uma parte importante e deixa de fora exatamente a parte que causa incidente. Uma ferramenta de comparação de especificação pega bem as mudanças estruturais: campo removido, tipo alterado, obrigatoriedade adicionada, valor de enumerado retirado. Rodar isso no fluxo de integração contínua e falhar a construção diante de mudança incompatível elimina a classe inteira de quebra acidental, e é a primeira coisa a montar porque o retorno é imediato. O que ela não pega é a mudança que é compatível no esquema e incompatível no significado. Passar a devolver o total incluindo frete continua sendo um número no mesmo campo com o mesmo tipo, e nenhuma validação estrutural reclama, enquanto todo cliente que soma frete separadamente passa a cobrar a mais. O mesmo vale para mudança de unidade, de fuso horário em campo de data, de precisão decimal, de critério de ordenação de uma lista e de significado do valor nulo. Essas mudanças só aparecem em teste de contrato com exemplo concreto e valor esperado, e no monitoramento do comportamento do cliente depois da implantação. A combinação que funciona é validação estrutural automatizada para o que é verificável, teste de contrato com dados reais para o significado, e medição de uso por credencial para decidir a remoção.

Contrato se evolui com evidência, não com aviso

Um número de versão na URL não impede que a mudança de significado vaze para o cliente antigo, e um aviso no changelog não prova que alguém leu. Posso revisar como a sua API evolui e definir a classificação de mudanças por compatibilidade real, a instrumentação de uso por credencial com cobertura de janela, o roteiro de expandir e contrair aplicado ao payload, os cabeçalhos de descontinuação com registro auditável de entrega, e os alertas que autorizam a remoção por cliente em vez de por prazo.