Idempotência e reentrega
A IORQ garante entrega no mínimo uma vez (at-least-once) — não exatamente uma vez. Você pode receber o mesmo evento duas ou mais vezes. Esta página explica por que isso acontece e como construir seu consumidor para ser correto sob duplicação.
1. Por que ocorre duplicação
Três cenários produzem duplicação legítima:
- Timeout do seu lado: a IORQ envia o evento, você processa, mas sua resposta demora demais. A IORQ assume falha e reenvia. Seu sistema processou duas vezes.
- Erro
5xxcom efeito colateral: seu endpoint persistiu o evento mas explodiu antes de responder. A IORQ reenvia, sua persistência grava de novo. - Reenvio manual (replay): você pediu a reentrega de um evento (
POST /api/events/{event_id}/replay) para reconciliar uma divergência, ou de um período inteiro (POST /api/events/replay) depois de uma queda.
2. Como deduplicar
2.1 Webhook v2: deduplique pelo identificador do evento
O envelope v2 identifica o fato, e você não precisa derivar chave nenhuma do conteúdo. Use a idempotency_key: ela é derivada do tipo e do sujeito do fato e é a mesma em toda reentrega e em todo replay do mesmo fato.
A chave é a mesma nos dois caminhos de entrega, o do produtor que já publica em v2 e o do envelope remontado a partir do v1. A exceção é uma decisão de recompra registrada antes de 28/09/2026, que num replay ainda chega com a chave da cessão: ver Catálogo de eventos.
O webhook.test é o outro caso à parte, e de propósito: a chave leva o event_id da chamada (webhook.test:credit_vehicle:<fund_id>:<event_id>), então cada teste é um fato novo.
def handle_webhook(envelope):
key = envelope["idempotency_key"]
if processed_events.exists(key):
return 200 # já processado, ack e ignora
with database.transaction():
process_event(envelope)
processed_events.insert(key)
return 200O 200 da duplicata pressupõe que a assinatura já foi validada: "ack e ignora" é para corpo autenticado que você não quer processar. Assinatura inválida é 401, sem processar (Configuração e segurança).
Os outros identificadores servem para outra coisa, e nenhum substitui a chave:
| Campo | Onde está | O que é |
|---|---|---|
webhook-id | Header | O evento no registro de entregas da IORQ, o mesmo em todas as tentativas; chega em toda entrega, nos dois ambientes. Deduplica as reentregas automáticas, mas o replay de um evento que já teve tentativa cria um evento novo, com webhook-id novo, e por isso ele não substitui a chave (ver Configuração e segurança) |
X-IORQ-Event-Id | Header | O mesmo valor do webhook-id. Chega em sandbox; em produção ainda não é enviado, e por isso não dá para depender dele hoje (ver O que muda entre sandbox e produção) |
id | Envelope, prefixo evt_ | Identifica o envelope. Enquanto o produtor publica em v1 e a IORQ converte para v2 na entrega, ele é gerado a cada entrega, então não serve como chave de deduplicação nessa fase |
X-IORQ-Delivery-Id | Header | A tentativa. Muda a cada POST, e é exatamente o que uma chave não pode fazer |
2.2 Webhook v1: deduplique pelo conteúdo
O corpo v1 está descrito em Contrato v1 (legado).
As entregas trazem, num webhook com secret, a assinatura webhook-signature (autenticidade — ver Configuração e segurança) e o webhook-id, que é o mesmo em todas as tentativas do evento e basta para descartar uma reentrega automática. Mas o corpo v1 não traz um event_id, e o replay de um evento que já teve tentativa chega com webhook-id novo: para cobrir os dois casos, deduplique pelo conteúdo do data — uma chave estável derivada de entity_id + status (+ installment_code e due_date quando existirem), com o iorq_fund_id do corpo como prefixo:
def handle_webhook(payload):
data = payload["data"]
fund_id = payload["iorq_fund_id"]
key = ":".join([
fund_id,
data["entity_id"],
data["status"],
data.get("installment_code", ""),
data.get("due_date", ""), # só existe em due_date_changed
])
if processed_events.exists(key):
return 200 # já processado, ack e ignora
with database.transaction():
process_event(payload)
processed_events.insert(key)
return 200Pontos de atenção:
- Tabela de chaves processadas com índice único — violar a constraint = duplicação detectada.
- Mesmo transaction-scope entre processar o evento e inserir a chave — sem isso, dá race.
due_date_changedé repetível por natureza: a mesma parcela pode ter o vencimento alterado mais de uma vez (dentro da janela permitida), gerando webhooks distintos com mesmoentity_id+status+installment_code. É por isso que a chave acima incluidata.due_date; nos outros status o campo não vem e a parte fica vazia.approvederejectedpodem ser dois fatos: a decisão de cessão e, depois, a de recompra da mesma operação chegam com o mesmoentity_id+status. A chave acima descarta a segunda. Se você pede recompra, trate essa colisão (ver Contrato v1 (legado)) ou migre para o v2.iorq_fund_idna chave — se você recebe mais de um fundo na mesma URL,entity_idsó é único dentro do fundo. Sem o prefixo, uma operação homônima em outro fundo é descartada como duplicata.event_timestampnão substitui a chave, e não a estraga. O campo é estável entre as tentativas do mesmo evento e numa reentrega manual carrega o instante do original (ver Catálogo de eventos), então incluí-lo na chave não impede a deduplicação de uma reentrega. Mas ele também não identifica o evento: dois fatos distintos podem compartilhar o instante, e é por isso que a chave continua sendo derivada dodata. Use-o para o que a chave não responde — quão atrasada está esta entrega.- Não use
webhook-timestamp(nem o legadoX-IORQ-Timestamp) para isso. Esse header muda a cada tentativa, então incluí-lo em qualquer chave de deduplicação faz cada reentrega parecer um evento novo — exatamente a falha que a tabela de chaves existe para evitar.
3. Política de retentativa da IORQ
A política de retentativa é a mesma nas duas versões de contrato: ela é do webhook, não do formato do corpo.
Quando a IORQ não recebe 2xx, ela reenvia seguindo uma agenda fixa de esperas, até o limite de tentativas do webhook. A IORQ espera 10 segundos pela sua resposta; passado esse tempo ela aborta a conexão e conta como tentativa falha, mesmo que o seu endpoint termine o processamento depois. Responda 2xx assim que receber o corpo e processe em segundo plano.
| Tentativa que falhou | Espera até a próxima |
|---|---|
| 1 | 30 s |
| 2 | 2 min |
| 3 | 10 min |
| 4 | 30 min |
| 5 | 1 h |
| 6 | 3 h |
| 7 | 6 h |
| 8 | 12 h |
| 9 | 24 h |
| 10 e seguintes | 24 h |
Com o padrão de 11 tentativas, a primeira entrega e 10 retentativas, a janela entre a primeira e a última é de 254.550 segundos, cerca de 70,7 horas, quase três dias. Só uma resposta 2xx conta como entregue; qualquer outra, inclusive 3xx, e o timeout de 10 segundos contam como tentativa falha. Um endpoint fora do ar por uma tarde inteira, ou por um fim de semana curto, volta e recebe tudo.
Os dois prazos desta página andam juntos, de propósito: a janela de reentrega fecha por volta das 70 horas, e a suspensão vem com três dias sem nenhum 2xx, medida pelo webhook e não pelo evento (regras em Configurando webhooks).
max_attempts é seu: mande o campo no POST /api/webhook ou no PATCH /api/webhook/{webhook_id}, entre 1 e 30. Se você não mandar, vale a política da IORQ, e o seu webhook acompanha as mudanças dela. O GET mostra o número que vale para o seu webhook.
Webhook criado antes de 25/09/2026 pode estar com 7 tentativasUm webhook registrado antes desta agenda pode ter
max_attempts: 7gravado, o que dá uma janela de cerca de 4,7 h em vez de 70 h. Confira noGET /api/webhook/{webhook_id}. Para passar à política atual agora, façaPATCH /api/webhook/{webhook_id}com{"max_attempts": 11}. O valor passa a ser seu, e não acompanha uma mudança futura da política. A IORQ também vai migrar esses webhooks.
Esgotadas as tentativas, a entrega fica registrada como falha e disponível para replay. Consulte o histórico via GET /api/attempts/event/{event_id}.
Webhook sem nenhum sucesso por três dias é suspensoUm webhook que passa três dias sem nenhuma entrega com
2xx, ou que esgota a agenda inteira de um evento com o endpoint indisponível, vai aSUSPENDED; os eventos continuam sendo gerados e guardados, inclusive os que estavam no meio da reentrega. As regras, o campofailing_since, como monitorar e como reativar estão em Configurando webhooks; depois de reativar, recupere o período com o replay por intervalo da seção 5.
Não dependa apenas dos webhooksA reentrega cobre uma queda longa, não uma queda indefinida. Implemente reconciliação periódica como salva-vidas. Quando o evento traz
data.loan.id, useGET /api/loan/{id_loan}, a consulta exata. Em produção, hoje,data.loan.idchega nulo em todo evento (ver Catálogo de eventos): até a virada do produtor, reconcilie pela listagemGET /api/loan/com o filtrooriginator_proposal_codee confira o valor exato no resultado. O filtro casa por trecho e sem diferenciar maiúsculas, então devolve também as operações cujo código contém o seu.
4. Garantia de ordem
A ordem de chegada não é garantida. Com a janela longa isto fica mais visível: uma tentativa que sai doze horas depois chega atrás de eventos mais novos da mesma operação, e eventos de operações diferentes chegam em qualquer ordem. Três regras:
- O estado atual é a fonte de verdade. Diante de uma dúvida de sequência, consulte a operação na API e aplique o que ela diz, não o que a ordem dos webhooks sugere.
- O instante do evento só serve para descartar o velho. Guarde, por sujeito, o
occurred_at(ou oevent_timestamp, no v1) do último evento aplicado. Uma entrega mais velha que ele para o mesmo sujeito está atrasada: não a aplique por cima do estado mais novo. O campo não diz se um evento intermediário chegou, então ele não reconstrói a sequência. - No envelope remontado do v1, o caso de produção hoje,
occurred_até o instante em que o evento entrou na IORQ para entrega, não o instante do fato. Dois fatos próximos da mesma operação podem entrar na IORQ em ordem diferente da ordem em que aconteceram; na dúvida, vale a primeira regra.
Isso vale inclusive para os dois tempos da baixa de parcela. No fluxo, installment.settled vem primeiro, quando a IORQ registra o pagamento, e installment.settlement_completed vem depois, quando a administradora confirma a baixa, normalmente algumas horas ou alguns dias depois. Os dois nascem em serviços diferentes e viajam por filas diferentes, então a ordem do fluxo não é uma ordem garantida de entrega. Processe cada um por si: receber o segundo sem ter visto o primeiro não significa que o primeiro se perdeu.
Num webhook v2 o campo é o occurred_at do envelope; num webhook v1, o event_timestamp injetado no topo. Os dois servem para a mesma coisa e seguem as regras acima.
5. Reentregas manuais
Há dois caminhos, e cada um tem o seu uso:
- Replay por
event_id(POST /api/events/{event_id}/replay) é para um evento específico que esgotou as tentativas com falha. A resposta é201com oEventclone, que aponta para o original emreplayed_from_event_id. Um409comcode: event_not_replayablesignifica que não há o que reentregar: a última tentativa foi bem-sucedida; ou a reentrega ainda está agendada, e aí é só aguardar, a IORQ ainda vai tentar; ou o evento ainda não teve nenhuma tentativa (gerado durante uma suspensão, por exemplo), e esse volta pelo replay por intervalo. A chamada também aceita um evento cuja reentrega foi interrompida pela suspensão ou pausa do webhook, mas você não precisa calcular a agenda para descobrir quais: para isso existe o caminho abaixo. - Replay por intervalo (
POST /api/events/replay, abaixo) é o caminho para recuperar depois de uma queda ou de uma suspensão. Você dá o intervalo e a IORQ examina os eventos e reenfileira o que ficou para trás, esgotados, interrompidos e nunca tentados, sem que você precise classificá-los. A resposta é202.
Nos dois, o webhook precisa estar ACTIVE: fora dele, 409 com webhook_not_deliverable (reative antes). Não há como apagar um webhook, então um evento sempre tem o seu. As duas chamadas exigem a permissão wsm_replay_event (Permissões). O replay de um evento:
curl -X POST 'https://webhook-sender-manager-production.iorq.com.br/api/events/{event_id}/replay' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'iorq-fund-id: 374485cf-f6df-467c-b91d-9f14082c6f36'Ou listar as tentativas de entrega de um evento:
curl 'https://webhook-sender-manager-production.iorq.com.br/api/attempts/event/{event_id}' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'iorq-fund-id: 374485cf-f6df-467c-b91d-9f14082c6f36'Recuperar um período inteiro
Depois de uma queda longa, ou de uma suspensão, reenviar evento a evento não escala. POST /api/events/replay reenfileira tudo que ficou para trás num intervalo:
curl -X POST 'https://webhook-sender-manager-production.iorq.com.br/api/events/replay' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'iorq-fund-id: 374485cf-f6df-467c-b91d-9f14082c6f36' \
-H 'Content-Type: application/json' \
-d '{
"webhook_id": "01K4Q8ZJ0000000000000000WH",
"date_from": "2026-09-20T00:00:00-03:00",
"date_to": "2026-09-24T00:00:00-03:00"
}'date_from e date_to são o intervalo de criação dos eventos, em ISO-8601 com fuso (2026-09-20T00:00:00-03:00 ou 2026-09-20T03:00:00Z). Sem fuso, ou fora de ISO-8601, a resposta é 422; date_to tem de ser depois de date_from. É a única chamada que exige o fuso: a listagem de eventos aceita data sem fuso e a lê como UTC (a tabela de relógios está em Configurando webhooks).
A resposta é 202 com {count, scanned, next_page_token, failed_event_ids}: count, quantos voltaram à fila; scanned, quantos foram examinados. Cada chamada examina até 200 eventos: repasse o next_page_token recebido no corpo da chamada seguinte até ele vir null.
Se algum evento da página não conseguir voltar à fila, ele aparece em failed_event_ids e o cursor vem assim mesmo. Não repita a mesma página: os que já voltaram seriam reenfileirados de novo e o seu endpoint receberia a entrega em duplicidade. Siga o cursor até o fim e depois reprocesse só o intervalo desses eventos.
O que volta:
- os eventos que esgotaram as tentativas com falha, como clones;
- os eventos cuja reentrega foi interrompida, por exemplo pela suspensão ou pela pausa do webhook: a última tentativa falhou, não era a final, e a próxima já deveria ter saído há mais de 30 minutos;
- os que nunca chegaram a ser tentados, tipicamente os gerados durante uma suspensão.
O que não volta: os já entregues com sucesso; os que têm reentrega ainda agendada, porque a IORQ ainda vai tentar sozinha; e os nunca tentados com menos de 15 minutos, que podem estar a caminho da primeira entrega. Fatos que aconteceram enquanto o webhook estava INACTIVE não geraram evento para ele e não voltam.
O webhook precisa estar ACTIVE: num webhook INACTIVE ou SUSPENDED a chamada devolve 409 com code: webhook_not_deliverable, pedindo a reativação primeiro.
6. Idempotência em requests da API
Para chamadas de você para a IORQ, a chave de negócio é o originator_proposal_code. Não há header Idempotency-Key nem campo idempotency_key:
| Endpoint | Chave / dedup |
|---|---|
POST /api/loan/ | originator_proposal_code (reenvio no mesmo minuto retorna a operação existente, sem duplicar) |
POST /api/installment/settle | Dedup interno por operação/parcela |
POST /api/installment/prepayment/v2 | Dedup interno por operação/parcela |
POST /api/loan/repurchase | originator_proposal_code |
PATCH /api/loan/renegotiate | renegotiated_loans[].originator_proposal_code + new_loan.originator_proposal_code |
7. Próximos passos
Updated 10 days ago
