Pular para o conteúdo principal

Idempotencia

As rotas financeiras exigem o header Idempotency-Key para evitar duplicidade em caso de timeout ou retentativa. Nas demais rotas mutantes o header e opcional, mas se voce enviar, ele e honrado do mesmo jeito.

Onde a chave e OBRIGATORIA (lista canonica)​

Estas — e somente estas — sao as rotas financeiras. Chamada sem Idempotency-Key e recusada com 422 idempotency_key_required antes de qualquer efeito:

RotaO que cria
POST /v1/operations/directOperacao de antecipacao (fluxo direto)
POST /v1/stock/request-anticipationOperacao de antecipacao (fluxo de estoque)
POST /v1/stockItem de recebivel no estoque
// 422 — rota financeira sem o header
{"detail": {"code": "idempotency_key_required",
"message": "Idempotency-Key header is required for financial operations"}}

A mesma lista aparece no OpenAPI: nessas tres operacoes o parametro Idempotency-Key esta declarado com required: true. Ambos saem da mesma fonte no codigo — nao ha lista paralela para dessincronizar.

Onde a chave e OPCIONAL (mas honrada)​

Qualquer POST / PUT / PATCH / DELETE sob /v1/* participa do cache de idempotencia quando o header e enviado. Sem o header, a requisicao e processada normalmente, sem protecao contra duplicidade. Vale a pena enviar em qualquer mutacao que voce va re-tentar (ex.: POST /v1/assignors, POST /v1/webhooks, POST /v1/assignor-payables).

Excecoes — o header e ignorado em POST /v1/auth/login e POST /v1/auth/token (troca de credencial por token nao e deduplicada). Em GET/HEAD/OPTIONS o header nao tem efeito algum.

Rotas sem efeito colateral​

POST /v1/simulate e POST /v1/stock/simulate-anticipation nao criam nada no sistema — sao calculos read-only. Sao idempotentes por natureza e podem ser re-tentados livremente (timeout, 5xx, falha de transporte) sem Idempotency-Key. Enviar a chave e inofensivo, mas so serve para receber o mesmo resultado cacheado por 24h — o que geralmente nao e o que voce quer numa simulacao (as taxas podem ter mudado).

Como funciona​

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  1. Na primeira chamada, a API reserva a chave antes de executar, processa e armazena a resposta
  2. Em chamadas subsequentes com a mesma chave, retorna a resposta armazenada sem reprocessar, com o header Idempotency-Replayed: true
  3. Se o body mudar com a mesma chave, retorna 409 idempotency_key_reused_with_different_body
  4. Se a primeira chamada ainda estiver em andamento, a segunda espera por ela e devolve a mesma resposta; se a espera estourar, responde 409 idempotency_key_in_flight — nunca executa duas vezes

O passo 1 e o que torna o retry concorrente seguro: sem a reserva, dois retries disparados ao mesmo tempo (o caso classico de timeout curto) nao encontravam nada no cache e os dois executavam.

Regras​

RegraValor
FormatoUUID recomendado; 16 a 80 caracteres, validado na entrada — fora da faixa: 422 idempotency_key_invalid
TTL24 horas
Leitura (replay)Isolada por originator_id + key — voce nunca recebe a resposta de outro originador
Escrita (gravacao no cache)Isolada por originator_id + key — dois originadores podem usar a mesma string sem interferir
Obrigatoria emAs 3 rotas financeiras da tabela acima
Aceita emQualquer POST / PUT / PATCH / DELETE sob /v1/*
Ignorada emPOST /v1/auth/login, POST /v1/auth/token
Janela maxima de uma chave em vooAte 600 segundos (TETO, nao duracao — ver abaixo)
Chave fora de 16–80 caracteres: 422 ANTES de qualquer efeito

Desde 28/07/2026 os limites de 16 a 80 caracteres sao validados na entrada, junto do idempotency_key_required:

// 422
{"detail": {"code": "idempotency_key_invalid",
"message": "Idempotency-Key must be between 16 and 80 characters (got 5); use a UUID"}}

Nada e criado quando este erro sai — corrija a chave e retente.

O que era antes (e por que mudou): o limite ja existia, mas so era aplicado na gravacao do cache, depois que a requisicao ja tinha rodado. Com uma chave fora da faixa numa rota financeira a sequencia era: a API aceitava o header sem validar, a operacao era criada, o 201 voltava, a gravacao do cache falhava e o erro era engolido — e um retry com a mesma chave duplicava a operacao. A protecao falhava em silencio exatamente na rota que o Idempotency-Key existe para proteger. Gere sempre um UUID (uuidgen, uuid.uuid4(), crypto.randomUUID() — 36 caracteres); nunca use contadores, hashes truncados ou strings curtas.

Colisao de chave entre originadores: resolvida

A identidade do registro no cache e o par (chave, originador) — leitura e escrita. Dois originadores podem usar a mesma string sem que um tire a protecao do outro, e o replay nunca cruza originadores. (Ate a versao anterior a escrita usava a chave sozinha: o segundo originador processava normalmente mas ficava sem protecao contra duplicidade. Isso acabou.) Chaves aleatorias (UUID) continuam sendo a recomendacao — nao derive a chave de contadores.

Abortar a conexao no meio LIBERA a chave

Se voce cancelar a requisicao (timeout do seu cliente, Ctrl-C, conexao caida) antes de a API responder, a reserva daquela chave e liberada — o retry com a MESMA chave executa de novo, porque nada foi concluido para replayar. Isso e deliberado: o contrario travaria a chave sem nunca ter uma resposta a devolver. Consequencia pratica: um cancelamento que acontece DEPOIS de a operacao ja ter sido gravada do nosso lado (janela curta, entre o commit e o envio da resposta) pode fazer o retry duplicar. Use external_id nas rotas que o aceitam quando seu cliente cancela requisicoes agressivamente.

Respostas 5xx nao sao armazenadas

Se a API responder 500+, nada e gravado no cache — re-tentar com a mesma chave re-executa a requisicao (que e exatamente o comportamento desejado). Respostas 2xx e 4xx sao armazenadas e re-servidas identicas.

Segredos nao voltam no replay

Rotas que devolvem um segredo de uso unico (hmac_secret de POST /v1/webhooks, access_token, client_secret, signer_token, ...) entregam esse valor apenas na primeira resposta. No replay pelo cache de idempotencia a chave continua presente, mas com valor null. Guarde o segredo na primeira entrega — o retry nao o recupera.

Exemplo​

# Primeira chamada — processa normalmente
curl -X POST https://receivables-api-sandbox.zemocapital.com/v1/stock \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"external_id": "NF-001", ...}'
# -> 201 Created

# Segunda chamada (retry) — retorna resposta cacheada
curl -X POST https://receivables-api-sandbox.zemocapital.com/v1/stock \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"external_id": "NF-001", ...}'
# -> 201 Created (mesma resposta, sem reprocessar)

Erros​

Chave reutilizada com body diferente:

// 409
{"detail": "idempotency_key_reused_with_different_body"}

A mesma chave usada em OUTRA rota (a chave e por OPERACAO, nao por sessao):

// 409
{"detail": {"code": "idempotency_key_reused_on_different_route",
"message": "This Idempotency-Key was already used on a different route; use a fresh key per operation"}}

Uma requisicao com a mesma chave ainda esta sendo processada (retry concorrente) e nao terminou dentro da janela de espera:

// 409
{"detail": {"code": "idempotency_key_in_flight",
"message": "A request with this Idempotency-Key is still being processed; retry shortly to get the original response"}}

Este 409 nao significa falha: a primeira chamada esta rodando. Re-tente com a mesma chave em alguns instantes para receber a resposta original.

Ate quanto tempo uma chave pode ficar em voo: teto de 600 segundos

No caso normal a chave e liberada assim que a execucao termina — o 409 dura o tempo da requisicao. Existe um caso raro em que dura mais: se o processo que detem a execucao morrer sem conseguir liberar a reserva (OOM, kill -9, queda do container), a chave fica indisponivel ate a reserva expirar sozinha.

Esse limite e de ate 600 segundos, e o numero e um TETO, nao uma duracao garantida: a maioria esmagadora dos casos e ordens de grandeza mais rapida, e o teto pode ser encurtado sem aviso (encurtar e compativel). Alargar a janela exigiria uma versao nova da API.

O que voce faz e o mesmo nos dois casos: continue re-tentando com a MESMA chave. Enquanto a resposta for 409 idempotency_key_in_flight, nada foi duplicado. Passado o teto, a reserva expira e o retry seguinte volta a executar normalmente — com seguranca, porque a execucao anterior nao chegou a concluir.

503 idempotency_unavailable — controle de idempotencia indisponivel​

Resposta possivel em qualquer rota mutante /v1/* a que voce envie Idempotency-Key:

// 503
{"detail": {"code": "idempotency_unavailable",
"message": "Idempotency control is unavailable; the request was NOT executed. Retry with the same Idempotency-Key."}}

Quando ocorre: o backend do controle de idempotencia esta indisponivel — a API nao consegue nem reservar a chave nem consultar o cache — e a API esta em modo fail-closed. Nesse modo ela prefere recusar a executar sem protecao: a garantia de "uma execucao por chave" vale mais do que a disponibilidade da rota.

O que voce faz: a mensagem e literal — a requisicao NAO foi executada, nada foi criado ou debitado. Re-tente com a MESMA Idempotency-Key, com backoff. Nao gere uma chave nova: a chave antiga e o que garante que, se algo tiver acontecido do nosso lado apesar de tudo, o retry replaye em vez de duplicar.

Vigencia: hoje este 503 nao e emitido

O modo fail-closed esta desligado na configuracao atual da API — na pratica, uma indisponibilidade do controle de idempotencia hoje faz a requisicao seguir sem a protecao de deduplicacao (comportamento historico), e nao um 503.

Este codigo esta declarado no contrato por antecipacao, de proposito: ligar o fail-closed no futuro passa a ser uma mudanca compativel (voce ja tratava o 503), em vez de um codigo de erro novo aparecendo numa versao ja publicada — que seria quebra de contrato. Trate-o desde ja e a mudanca nao te atinge.

Rotas de dinheiro sem Idempotency-Key: dedup por external_id​

Duas rotas que mexem em dinheiro nao estao na lista de rotas financeiras — o header continua opcional nelas — mas aceitam um campo external_id no corpo que da a MESMA garantia sem depender do header:

RotaCampoGrao da unicidade
POST /v1/assignor-payables/{id}/paymentsexternal_id (opcional, 1–80 chars)(payable, external_id)
POST /v1/discount-creditsexternal_id (opcional, 1–80 chars)(originador, external_id)

O external_id e comparado literalmente: nao ha normalizacao de caixa nem de espacos — "NF-001", "nf-001" e " NF-001" sao tres chaves diferentes. Envie sempre o mesmo valor, byte a byte, em todos os retries.

Enviando external_id, um retry com o mesmo valor devolve o registro original com idempotent_replay: true — sem segundo debito, sem segundo credito. Sem o campo, nada muda: dois POSTs iguais criam dois registros, que e o comportamento correto para, por exemplo, dois pagamentos parciais legitimos de mesmo valor no mesmo dia.

Unica ressalva (pagamentos), especifica do external_id: depois de um write-off, o dedup por external_id responde 409 payable_already_settled em vez de replayar. O write-off resolve a divida por decisao contabil e nao por pagamento, entao nao ha saldo honesto a devolver na resposta. Payable liquidado por pagamento replaya normalmente, inclusive o retry da propria chamada que o liquidou.

A ressalva e do mecanismo do external_id, que e resolvido no processamento da rota. Se a chamada original tambem enviou Idempotency-Key, o retry com a mesma chave dentro do TTL de 24h continua devolvendo a resposta original — inclusive depois do write-off —, porque o header e resolvido ANTES da rota, como especificado na secao do Idempotency-Key acima. Os dois mecanismos convivem; quando os dois se aplicam, o do header responde primeiro.

Reusar o mesmo external_id com um corpo diferente (outro valor, outra origem, outro cedente) e erro do integrador e responde 409, com a lista dos campos divergentes — nunca um 201 que descarta em silencio o que voce pediu:

// 409
{"detail": {"code": "external_id_reused_with_different_body",
"message": "This external_id was already used for a different payment on this payable; use a new external_id",
"fields": ["paid_amount_brl"]}}
curl -X POST .../v1/assignor-payables/$PAYABLE_ID/payments \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"source": "ASSIGNOR_PIX_DIRECT", "paid_amount_brl": "300.00",
"external_id": "pix-2026-0001"}'
# 1a chamada -> 201 {"idempotent_replay": false, "remaining": "700.00", ...}
# retry -> 201 {"idempotent_replay": true, "remaining": "700.00", ...}
Gere UUIDs unicos

Use uuidgen (Linux/Mac), uuid.uuid4() (Python) ou crypto.randomUUID() (Node.js).