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 5xx com 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 200

O 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:

CampoOnde estáO que é
webhook-idHeaderO 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-IdHeaderO 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)
idEnvelope, 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-IdHeaderA 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 200

Pontos 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 mesmo entity_id + status + installment_code. É por isso que a chave acima inclui data.due_date; nos outros status o campo não vem e a parte fica vazia.
  • approved e rejected podem ser dois fatos: a decisão de cessão e, depois, a de recompra da mesma operação chegam com o mesmo entity_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_id na chave — se você recebe mais de um fundo na mesma URL, entity_id só é único dentro do fundo. Sem o prefixo, uma operação homônima em outro fundo é descartada como duplicata.
  • event_timestamp nã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 do data. Use-o para o que a chave não responde — quão atrasada está esta entrega.
  • Não use webhook-timestamp (nem o legado X-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 falhouEspera até a próxima
130 s
22 min
310 min
430 min
51 h
63 h
76 h
812 h
924 h
10 e seguintes24 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 tentativas

Um webhook registrado antes desta agenda pode ter max_attempts: 7 gravado, o que dá uma janela de cerca de 4,7 h em vez de 70 h. Confira no GET /api/webhook/{webhook_id}. Para passar à política atual agora, faça PATCH /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 é suspenso

Um 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 a SUSPENDED; os eventos continuam sendo gerados e guardados, inclusive os que estavam no meio da reentrega. As regras, o campo failing_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 webhooks

A 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, use GET /api/loan/{id_loan}, a consulta exata. Em produção, hoje, data.loan.id chega nulo em todo evento (ver Catálogo de eventos): até a virada do produtor, reconcilie pela listagem GET /api/loan/ com o filtro originator_proposal_code e 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 o event_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 é 201 com o Event clone, que aponta para o original em replayed_from_event_id. Um 409 com code: event_not_replayable significa 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:

EndpointChave / dedup
POST /api/loan/originator_proposal_code (reenvio no mesmo minuto retorna a operação existente, sem duplicar)
POST /api/installment/settleDedup interno por operação/parcela
POST /api/installment/prepayment/v2Dedup interno por operação/parcela
POST /api/loan/repurchaseoriginator_proposal_code
PATCH /api/loan/renegotiaterenegotiated_loans[].originator_proposal_code + new_loan.originator_proposal_code

7. Próximos passos


Did this page help you?