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:
| Rota | O que cria |
|---|---|
POST /v1/operations/direct | Operacao de antecipacao (fluxo direto) |
POST /v1/stock/request-anticipation | Operacao de antecipacao (fluxo de estoque) |
POST /v1/stock | Item 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
- Na primeira chamada, a API reserva a chave antes de executar, processa e armazena a resposta
- Em chamadas subsequentes com a mesma chave, retorna a resposta armazenada
sem reprocessar, com o header
Idempotency-Replayed: true - Se o body mudar com a mesma chave, retorna
409 idempotency_key_reused_with_different_body - 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
| Regra | Valor |
|---|---|
| Formato | UUID recomendado; 16 a 80 caracteres, validado na entrada — fora da faixa: 422 idempotency_key_invalid |
| TTL | 24 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 em | As 3 rotas financeiras da tabela acima |
| Aceita em | Qualquer POST / PUT / PATCH / DELETE sob /v1/* |
| Ignorada em | POST /v1/auth/login, POST /v1/auth/token |
| Janela maxima de uma chave em voo | Ate 600 segundos (TETO, nao duracao — ver abaixo) |
422 ANTES de qualquer efeitoDesde 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.
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.
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.
5xx nao sao armazenadasSe 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.
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.
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.
503 nao e emitidoO 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:
| Rota | Campo | Grao da unicidade |
|---|---|---|
POST /v1/assignor-payables/{id}/payments | external_id (opcional, 1–80 chars) | (payable, external_id) |
POST /v1/discount-credits | external_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", ...}
Use uuidgen (Linux/Mac), uuid.uuid4() (Python) ou crypto.randomUUID() (Node.js).