Blog

Webhook recebido fora de ordem: reconciliar o estado quando o evento de cancelamento chega antes do de criação

Uma integração de pedidos recebe 1,4 milhão de webhooks por mês de uma plataforma de e-commerce e alimenta o sistema de separação do centro de distribuição. Em outubro, 37 pedidos cancelados pelo cliente foram separados e despachados, com R$ 21 mil entre frete de ida e volta, produto avariado e estorno. Nenhum erro no log: todos os eventos foram respondidos com 200. O cancelamento chegou antes da criação e do pagamento, e o handler tratava cada evento como uma ordem a executar sobre o estado atual. Este artigo mostra por que a ordem de entrega não é garantida, como medir quanto isso acontece no seu tráfego, como trocar comandos por fatos versionados, gravar antes de aplicar, decidir com uma função que não depende de quem chegou primeiro, disparar efeitos a partir da transição de estado e fechar as lacunas com uma reconciliação periódica na fonte.

2026-11-04 / Integrações / 15 min

01

Por que o provedor não entrega na ordem em que os fatos aconteceram

Quase toda documentação de webhook tem uma frase dizendo que a ordem de entrega não é garantida, e quase todo handler é escrito como se fosse. A ordem se perde porque cada evento é uma entrega independente: tem a própria fila de tentativas, o próprio backoff e, em muitos provedores, é despachado por um conjunto de workers em paralelo. Basta uma entrega falhar por timeout para que eventos posteriores do mesmo recurso passem na frente dela.

No caso da integração, o gatilho era previsível. Às 10h, a campanha de outubro dobrava o volume, o endpoint de webhook passava a responder em 4 a 6 segundos por causa de uma consulta ao ERP dentro do handler, e o provedor desistia em 5 segundos. A criação e o pagamento iam para a fila de reentrega com espera de 60 segundos; o cancelamento, feito pelo cliente 26 segundos depois, chegava num momento de folga e era entregue na primeira tentativa.

CausaComo embaralha a ordemJanela típica
Reentrega com backoffEvento que falhou volta depois dos que vieram em seguidaDe segundos a horas
Despacho paralelo no provedorEventos do mesmo recurso saem por workers diferentesMilissegundos a segundos
Consumidores paralelos do nosso ladoDois pods processam eventos do mesmo pedido ao mesmo tempoMilissegundos
Replays manuais pelo painel do provedorEvento antigo é reenviado dias depoisHoras a dias
Mais de uma origem para o mesmo recursoCheckout e backoffice emitem eventos por caminhos distintosSegundos

Antes de mudar qualquer coisa, vale medir. Se o handler já grava as entregas, a consulta abaixo conta quantos eventos chegaram depois de outro mais novo do mesmo recurso. Na integração do exemplo, o resultado foi 0,31% dos eventos, concentrados em order.paid e order.created nos horários de pico. Parece pouco, mas 0,31% de 1,4 milhão são mais de 4 mil eventos por mês aplicados sobre o estado errado.

-- Quantos eventos chegaram depois de um evento mais novo do mesmo pedido.
-- Roda sobre o log bruto de entregas que o handler antigo ja gravava.
WITH entregas AS (
  SELECT recurso_id,
         tipo,
         ocorrido_em,
         recebido_em,
         max(ocorrido_em) OVER (
           PARTITION BY recurso_id
           ORDER BY recebido_em
           ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
         ) AS mais_novo_ja_recebido
    FROM webhook_log
   WHERE recebido_em >= now() - interval '30 days'
)
SELECT tipo,
       count(*) AS total,
       count(*) FILTER (WHERE ocorrido_em < mais_novo_ja_recebido) AS fora_de_ordem,
       round(100.0 * count(*) FILTER (WHERE ocorrido_em < mais_novo_ja_recebido) / count(*), 2) AS pct
  FROM entregas
 GROUP BY tipo
 ORDER BY fora_de_ordem DESC;

02

O erro de origem: tratar cada evento como um comando

O handler original é o que quase todo mundo escreve na primeira versão: um if por tipo de evento, cada ramo executando uma operação sobre a linha que já existe. Ele funciona enquanto os eventos chegam em ordem e falha em silêncio quando não chegam, porque um UPDATE que não encontra linha não é um erro para o banco nem para o driver.

// O handler que assume ordem: cada evento vira um comando sobre o estado atual.
router.post('/webhooks/loja', express.json(), async (req, res) => {
  const { type, data } = req.body;

  if (type === 'order.created') {
    await pool.query(
      "INSERT INTO pedidos (id, status, total_centavos) VALUES ($1, 'criado', $2) ON CONFLICT (id) DO NOTHING",
      [data.id, data.total_cents],
    );
  } else if (type === 'order.paid') {
    // Se o pedido ainda nao existe, atualiza 0 linhas e o pagamento some sem erro.
    await pool.query("UPDATE pedidos SET status = 'pago' WHERE id = $1", [data.id]);
    await filaSeparacao.add({ pedidoId: data.id });
  } else if (type === 'order.canceled') {
    // Mesmo problema: cancelamento antes da criacao nao encontra linha e e descartado.
    await pool.query("UPDATE pedidos SET status = 'cancelado' WHERE id = $1", [data.id]);
  }

  res.sendStatus(200);
});

O diagrama mostra a sequência real de um dos 37 pedidos. Repare que nenhum passo, isolado, está errado: cada evento recebeu uma resposta válida e cada comando fez exatamente o que o código mandava. O defeito está na premissa de que o evento descreve o próximo passo, quando na verdade descreve um fato que aconteceu em algum momento no passado do provedor.

Provedor (ocorrido_em)            Handler que assume ordem
10:02:11  order.created  --x      timeout, reentrega agendada para 10:03:11
10:02:14  order.paid     --x      timeout, reentrega agendada para 10:03:14
10:02:40  order.canceled ----->   UPDATE status = 'cancelado' -> 0 linhas, responde 200
10:03:11  order.created  ----->   INSERT status = 'criado'
10:03:14  order.paid     ----->   UPDATE status = 'pago' + libera separacao

Estado final no nosso banco: pago, em separacao
Estado real no provedor:     cancelado

Com fatos versionados:
10:02:40  cancelado (10:02:40 + rank 4)  ->  pedido nao existe: cria como cancelado
10:03:11  criado    (10:02:11 + rank 1)  ->  versao menor: so completa total e e-mail
10:03:14  pago      (10:02:14 + rank 2)  ->  versao menor: ignorado, nenhum efeito
Estado final: cancelado, sem separacao
  • Cancelamento antes da criação: o UPDATE não encontra linha, o 200 confirma a entrega e o provedor nunca mais reenvia.
  • Pagamento antes da criação: o pedido fica eternamente como criado e não entra na separação; o cliente pagou e não recebe.
  • Efeito disparado pelo tipo do evento: a separação é liberada porque chegou order.paid, sem olhar se o pedido já estava cancelado.
  • Retry depois do 200: ignorar o problema e confiar no reenvio não funciona, porque do ponto de vista do provedor a entrega deu certo.

03

Fatos com versão no lugar de comandos

A virada é tratar cada evento como um fato datado sobre o recurso: "o pedido 8812 estava cancelado às 10:02:40". Fatos podem chegar em qualquer ordem, desde que exista uma regra determinística para decidir qual deles descreve o estado mais recente. Essa regra é a chave de ordenação, e a decisão passa a ser uma função pura do estado atual e do fato recebido.

A melhor chave é uma sequência por recurso fornecida pelo provedor, como um campo de versão que aumenta a cada alteração do pedido. Na falta dela, use o instante em que o fato ocorreu na origem, nunca o instante em que chegou até você. Como muitos provedores mandam esse instante com resolução de segundo, dois fatos do mesmo pedido podem empatar; o desempate pelo estado mais avançado resolve o caso clássico de pagamento e cancelamento no mesmo segundo, em que o cancelamento deve vencer.

Estado atualFato recebidoVersão do fatoDecisão
Não existecanceladoQualquerCria como cancelado, sem efeito colateral
canceladocriado ou pagoMenorIgnora o status; completa total e e-mail se faltarem
pagocanceladoMaiorAplica e registra interromper_separacao
canceladopagoMaiorNão aplica; registra regressao_de_terminal para investigar
canceladoreembolsadoMaiorAplica e registra notificar_reembolso
QualquerO mesmo fato de novoIgualIgnora: reentrega e reconciliação são idempotentes

Dois detalhes fazem a regra funcionar. Primeiro, campos que não mudam ao longo da vida do pedido, como total e e-mail, são completados por qualquer fato que os traga, mesmo um fato antigo; isso resolve o caso em que o cancelamento chega com um payload enxuto e a criação, atrasada, traz os dados. Segundo, estados terminais têm saídas explícitas: um pagamento com versão maior depois de um cancelamento não deve reabrir o pedido, e sim acender um alerta, porque indica um bug no provedor ou uma premissa errada sobre o domínio.

04

Gravar antes de aplicar: caixa de entrada, transição e outbox

O receptor passa a ter uma única responsabilidade síncrona: validar a assinatura, gravar o evento pela chave do provedor e responder. A gravação com ON CONFLICT DO NOTHING já resolve as reentregas duplicadas, e responder sem esperar o ERP elimina o timeout que disparava a maioria das reentregas fora de ordem. O resto do trabalho acontece depois, em uma transação por evento.

-- Caixa de entrada: todo evento e gravado antes de qualquer decisao.
CREATE TABLE webhook_eventos (
  evento_id    text        PRIMARY KEY,          -- id do evento no provedor: deduplica reentregas
  recurso_id   text        NOT NULL,             -- id do pedido a que o evento se refere
  tipo         text        NOT NULL,
  payload      jsonb       NOT NULL,
  recebido_em  timestamptz NOT NULL DEFAULT now(),
  aplicado_em  timestamptz,
  resultado    text                              -- aplicado, evento_antigo, regressao_de_terminal...
);

-- A varredura so procura o que ainda nao foi aplicado.
CREATE INDEX webhook_eventos_pendentes
    ON webhook_eventos (recebido_em) WHERE aplicado_em IS NULL;

CREATE TABLE pedidos (
  id              text        PRIMARY KEY,       -- id do pedido no provedor
  status          text        NOT NULL
                  CHECK (status IN ('criado', 'pago', 'enviado', 'cancelado', 'reembolsado')),
  total_centavos  bigint,                        -- pode chegar depois: o primeiro fato nem sempre traz tudo
  cliente_email   text,
  versao_origem   bigint      NOT NULL,          -- chave de ordenacao do ultimo fato aplicado
  atualizado_em   timestamptz NOT NULL DEFAULT now()
);

-- Efeitos colaterais nascem da transicao de estado, na mesma transacao.
CREATE TABLE outbox (
  pedido_id   text        NOT NULL,
  efeito      text        NOT NULL,
  criado_em   timestamptz NOT NULL DEFAULT now(),
  enviado_em  timestamptz,
  PRIMARY KEY (pedido_id, efeito)
);

-- Ponto de partida da reconciliacao periodica.
CREATE TABLE cursores (
  nome   text        PRIMARY KEY,
  valor  timestamptz NOT NULL
);
INSERT INTO cursores (nome, valor) VALUES ('pedidos_loja', now() - interval '1 day');

A aplicação usa um advisory lock por pedido, obtido com pg_advisory_xact_lock sobre o hash do id. Isso serializa os fatos de um mesmo pedido mesmo quando a linha ainda não existe, situação em que um SELECT FOR UPDATE não teria o que travar e dois eventos simultâneos poderiam ambos concluir que o pedido é novo. O lock dura só até o fim da transação e não bloqueia pedidos diferentes.

// webhooks/aplicar.js - aplica fatos sobre pedidos sem depender da ordem de chegada.
import { pool } from '../db.js';

// Desempate quando dois fatos tem o mesmo instante: o estado mais avancado vence.
export const RANK = { criado: 1, pago: 2, enviado: 3, cancelado: 4, reembolsado: 5 };

// Estados de onde so se sai para os destinos listados. Fato mais novo fora disso e anomalia.
const TERMINAIS = { cancelado: ['reembolsado'], reembolsado: [] };

const STATUS_DO_TIPO = {
  'order.created': 'criado',
  'order.paid': 'pago',
  'order.shipped': 'enviado',
  'order.canceled': 'cancelado',
  'order.refunded': 'reembolsado',
};

const STATUS_DA_API = {
  open: 'criado',
  paid: 'pago',
  shipped: 'enviado',
  canceled: 'cancelado',
  refunded: 'reembolsado',
};

// Um provedor, um criterio: sequencia por recurso quando existe; senao, instante do fato em ms
// vezes 10 mais o rank, o que cabe com folga em Number.MAX_SAFE_INTEGER.
export function versaoDoFato({ sequence, occurredAt }, status) {
  if (Number.isSafeInteger(sequence)) return sequence;
  const ms = Date.parse(occurredAt);
  if (Number.isNaN(ms)) throw new Error(`instante invalido: ${occurredAt}`);
  return ms * 10 + RANK[status];
}

// Funcao pura: o resultado depende do estado e do fato, nunca de quem chegou primeiro.
export function decidir(atual, status, versao) {
  if (!atual) return { aplicar: true, de: null };
  if (versao <= Number(atual.versao_origem)) return { aplicar: false, motivo: 'evento_antigo' };
  const saidas = TERMINAIS[atual.status];
  if (saidas && !saidas.includes(status)) {
    return { aplicar: false, motivo: 'regressao_de_terminal' };
  }
  return { aplicar: true, de: atual.status };
}

// Efeitos saem da transicao real, nao do tipo do evento recebido.
function efeitosDaTransicao(de, para) {
  if (para === 'pago') return ['liberar_separacao'];
  if (para === 'cancelado' && de === 'pago') return ['interromper_separacao'];
  if (para === 'reembolsado') return ['notificar_reembolso'];
  return [];
}

// Precisa rodar dentro de uma transacao aberta pelo chamador.
export async function aplicarFato(client, { recursoId, status, versao, dados }) {
  if (!status) throw new Error(`status desconhecido para o pedido ${recursoId}`);

  // Serializa os fatos do mesmo pedido, inclusive quando a linha ainda nao existe.
  await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [recursoId]);

  const { rows } = await client.query('SELECT status, versao_origem FROM pedidos WHERE id = $1', [
    recursoId,
  ]);
  const decisao = decidir(rows[0], status, versao);
  const total = dados.total_cents ?? null;
  const email = dados.customer_email ?? null;

  if (!decisao.aplicar) {
    // Fato antigo nao muda o status, mas ainda completa campos que o fato novo nao trouxe.
    await client.query(
      `UPDATE pedidos
          SET total_centavos = COALESCE(total_centavos, $2),
              cliente_email  = COALESCE(cliente_email, $3)
        WHERE id = $1`,
      [recursoId, total, email],
    );
    return decisao.motivo;
  }

  await client.query(
    `INSERT INTO pedidos (id, status, total_centavos, cliente_email, versao_origem)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (id) DO UPDATE
        SET status         = EXCLUDED.status,
            total_centavos = COALESCE(EXCLUDED.total_centavos, pedidos.total_centavos),
            cliente_email  = COALESCE(EXCLUDED.cliente_email, pedidos.cliente_email),
            versao_origem  = EXCLUDED.versao_origem,
            atualizado_em  = now()`,
    [recursoId, status, total, email, versao],
  );

  for (const efeito of efeitosDaTransicao(decisao.de, status)) {
    await client.query(
      'INSERT INTO outbox (pedido_id, efeito) VALUES ($1, $2) ON CONFLICT DO NOTHING',
      [recursoId, efeito],
    );
  }
  return 'aplicado';
}

export async function aplicarEvento(eventoId) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(
      `SELECT evento_id, recurso_id, tipo, payload
         FROM webhook_eventos
        WHERE evento_id = $1 AND aplicado_em IS NULL
          FOR UPDATE SKIP LOCKED`,
      [eventoId],
    );
    const evento = rows[0];
    if (!evento) {
      await client.query('ROLLBACK');
      return null; // ja aplicado ou sendo aplicado por outro processo
    }

    const status = STATUS_DO_TIPO[evento.tipo];
    const resultado = status
      ? await aplicarFato(client, {
          recursoId: evento.recurso_id,
          status,
          versao: versaoDoFato(
            { sequence: evento.payload.sequence, occurredAt: evento.payload.occurred_at },
            status,
          ),
          dados: evento.payload.data,
        })
      : 'tipo_ignorado';

    await client.query(
      'UPDATE webhook_eventos SET aplicado_em = now(), resultado = $2 WHERE evento_id = $1',
      [evento.evento_id, resultado],
    );
    await client.query('COMMIT');
    return resultado;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

// Mesmo formato de fato a partir do pedido lido na API do provedor.
export function fatoDaApi(pedido) {
  const status = STATUS_DA_API[pedido.status];
  return {
    recursoId: pedido.id,
    status,
    versao: status
      ? versaoDoFato({ sequence: pedido.sequence, occurredAt: pedido.updated_at }, status)
      : null,
    dados: pedido,
  };
}

Os efeitos colaterais deixaram de depender do tipo do evento. A separação é liberada quando o pedido transiciona para pago, e essa transição só acontece se o fato for mais novo que o estado atual. Por isso o pagamento atrasado, que chega depois do cancelamento, não gera nada. A intenção é gravada na tabela outbox na mesma transação, com chave por pedido e efeito, e um publicador separado envia para o sistema de separação; se a transação falhar, nem o estado nem o efeito existem.

// webhooks/receptor.js - valida, grava, responde e so depois aplica.
// Express 5: erros lancados em handlers async chegam ao middleware de erro sem wrapper.
import crypto from 'node:crypto';
import express from 'express';
import { pool } from '../db.js';
import { aplicarEvento } from './aplicar.js';

export const router = express.Router();

function assinaturaValida(corpo, assinatura) {
  if (typeof assinatura !== 'string') return false;
  const esperada = crypto
    .createHmac('sha256', process.env.LOJA_WEBHOOK_SECRET)
    .update(corpo)
    .digest('hex');
  const a = Buffer.from(assinatura, 'utf8');
  const b = Buffer.from(esperada, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

router.post('/webhooks/loja', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!assinaturaValida(req.body, req.get('x-loja-signature'))) return res.status(401).end();

  const evento = JSON.parse(req.body.toString('utf8'));
  await pool.query(
    `INSERT INTO webhook_eventos (evento_id, recurso_id, tipo, payload)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (evento_id) DO NOTHING`,
    [evento.id, evento.data.id, evento.type, evento],
  );
  res.status(200).end();

  // Fora da resposta: se falhar aqui, o evento continua gravado e a varredura tenta de novo.
  aplicarEvento(evento.id).catch((err) =>
    console.error('falha ao aplicar evento', evento.id, err.message),
  );
});

// Varredura: reaplica o que ficou pendente por erro, deploy no meio ou queda do processo.
setInterval(async () => {
  try {
    const { rows } = await pool.query(
      `SELECT evento_id
         FROM webhook_eventos
        WHERE aplicado_em IS NULL
          AND recebido_em < now() - interval '30 seconds'
        ORDER BY recebido_em
        LIMIT 200`,
    );
    for (const { evento_id: eventoId } of rows) {
      await aplicarEvento(eventoId).catch((err) =>
        console.error('varredura: falha no evento', eventoId, err.message),
      );
    }
  } catch (err) {
    console.error('varredura falhou', err.message);
  }
}, 15_000).unref();

05

Quando o payload não traz versão confiável

Nem todo provedor manda sequência, e alguns mandam um occurred_at que é, na prática, o horário de enfileiramento da entrega. Nesses casos, há três estratégias com custos diferentes, e a escolha depende do que o provedor garante e de quanto a API dele aguenta.

EstratégiaQuando usarCustoRisco
Sequência por recurso no payloadProvedor documenta um campo de versão monotônicoNenhumBaixo
Instante do fato com desempate por estadoHá timestamp de ocorrência, mesmo com resolução de segundoNenhumRelógio do provedor com regressão; raro, mas existe
Buscar o recurso na API ao receber o avisoPayload sem versão ou sem dados confiáveisUma chamada por eventoLimite de taxa e latência da API do provedor

A terceira estratégia trata o webhook apenas como um aviso de que algo mudou. Ao receber o evento, o worker consulta GET /orders/{id} e aplica o snapshot com a mesma função aplicarFato, usando o updated_at do recurso como versão. Como o snapshot é sempre o estado atual na origem, a ordem dos avisos deixa de importar. O preço é uma chamada por evento, então vale agrupar: se três avisos do mesmo pedido chegam em 2 segundos, uma única leitura depois do último resolve os três.

  • Agrupe avisos por recurso com uma espera curta, de 1 a 3 segundos, antes de consultar a fonte.
  • Respeite o limite de taxa do provedor com um semáforo global, e não por processo, para não estourar em escala horizontal.
  • Se a consulta retornar 404, não apague o pedido: registre a anomalia, porque alguns provedores demoram para tornar o recurso visível na API depois de emitir o evento.
  • Use a mesma chave de ordenação para fatos vindos do webhook e da API, ou a reconciliação vai brigar com os eventos.

06

Reconciliação periódica: a rede que pega o que o webhook perdeu

Mesmo com tudo acima, webhooks se perdem: o provedor desiste depois de esgotar as tentativas, um segredo rotacionado faz o 401 durar uma hora, um replay manual é esquecido. A reconciliação percorre os pedidos alterados na origem desde o último cursor e aplica cada um como fato, pela mesma função. Se o estado local já está correto, o resultado é evento_antigo e nada acontece; se não está, o pedido é corrigido e o efeito correspondente entra no outbox.

// webhooks/reconciliacao.js - roda a cada 15 minutos e pega o que o webhook nao entregou.
import { pool } from '../db.js';
import { aplicarFato, fatoDaApi } from './aplicar.js';

// Volta 10 minutos no cursor: pedidos atualizados perto da borda da janela anterior
// podem ter aparecido na listagem depois que ela foi lida.
const SOBREPOSICAO_MS = 10 * 60 * 1000;

export async function reconciliar() {
  const { rows } = await pool.query("SELECT valor FROM cursores WHERE nome = 'pedidos_loja'");
  let maior = rows[0].valor;
  const desde = new Date(maior.getTime() - SOBREPOSICAO_MS).toISOString();
  const contagem = {};
  let pagina = null;

  do {
    const url = new URL('/orders', process.env.LOJA_API);
    url.searchParams.set('updated_since', desde);
    url.searchParams.set('limit', '100');
    if (pagina) url.searchParams.set('page_token', pagina);

    const resp = await fetch(url, {
      headers: { authorization: `Bearer ${process.env.LOJA_TOKEN}` },
      signal: AbortSignal.timeout(10_000),
    });
    if (!resp.ok) throw new Error(`listagem de pedidos respondeu ${resp.status}`);
    const corpo = await resp.json();

    for (const pedido of corpo.orders) {
      const client = await pool.connect();
      try {
        await client.query('BEGIN');
        const resultado = await aplicarFato(client, fatoDaApi(pedido));
        await client.query('COMMIT');
        contagem[resultado] = (contagem[resultado] ?? 0) + 1;
      } catch (err) {
        await client.query('ROLLBACK');
        throw err;
      } finally {
        client.release();
      }
      const atualizado = new Date(pedido.updated_at);
      if (atualizado > maior) maior = atualizado;
    }
    pagina = corpo.next_page_token ?? null;
  } while (pagina);

  // So avanca o cursor depois de percorrer tudo: uma falha no meio repete a janela inteira.
  await pool.query("UPDATE cursores SET valor = $1 WHERE nome = 'pedidos_loja'", [maior]);

  // contagem.aplicado > 0 significa que o webhook perdeu algo: vira metrica e alerta.
  return contagem;
}

O contador que a função devolve é a métrica mais útil da integração. Em regime normal, aplicado fica em zero ou perto disso, porque o webhook chegou antes. Um salto indica que o webhook está falhando de algum jeito que ainda não apareceu em outro lugar, e esse alerta costuma disparar antes de qualquer reclamação de cliente. Vale acompanhar também a idade do evento pendente mais antigo e a contagem de regressao_de_terminal por dia.

Na integração do exemplo, a mudança foi feita em três deploys: primeiro a caixa de entrada gravando em paralelo ao handler antigo, para medir; depois a aplicação por fatos com a separação ainda disparada pelo caminho antigo, comparando as decisões; por fim, a troca dos efeitos para o outbox e a reconciliação a cada 15 minutos. Novembro fechou sem nenhum pedido cancelado despachado.

MétricaAntesDepois
Pedidos cancelados despachados por mês370
Pedidos pagos presos como criado2140
Tempo de resposta do endpoint de webhook (p95)4,8 s12 ms
Eventos reentregues pelo provedor por timeout2,9%0,02%
Pedidos corrigidos pela reconciliação por semanaNão existia3 a 6

FAQ

Perguntas frequentes

Não seria mais simples rejeitar com erro o evento que chega antes da criação, para o provedor reenviar depois?

Funciona em parte e cobra caro. Você passa a depender da política de reentrega do provedor, que pode desistir depois de algumas tentativas ou desativar o endpoint por excesso de falhas, e o evento fica atrasado por minutos sem necessidade. Além disso, não resolve o caso inverso, em que o pagamento atrasado chega depois do cancelamento e o pedido existe. Gravar tudo e decidir pela versão cobre os dois casos sem depender do comportamento do outro lado.

Uma fila com garantia de ordem, como um tópico particionado pelo id do pedido, não resolve?

Ela preserva a ordem em que os eventos entraram na sua fila, que já é a ordem de chegada dos webhooks, ou seja, já embaralhada. Particionar pelo id evita que dois consumidores seus processem o mesmo pedido em paralelo, o que é útil, mas não recupera a ordem em que os fatos aconteceram no provedor. Para isso você precisa de uma chave de ordenação vinda da origem.

E se o provedor não tiver API de listagem para a reconciliação?

Reconcilie pelo que você tem: consulte individualmente os pedidos em estados intermediários há mais tempo do que o esperado, como pagos sem envio há 48 horas ou criados sem pagamento há 2 horas, e aplique o snapshot de cada um. É menos completo que percorrer tudo por data de alteração, mas pega justamente os casos que custam dinheiro. Se nem consulta individual existir, a integração não tem como se autocorrigir e isso precisa ser tratado como risco do contrato com o provedor.

O webhook conta o que aconteceu, não o que fazer em seguida

Os 37 pedidos cancelados que saíram do centro de distribuição não foram causados por um bug exótico, e sim pela premissa de que eventos chegam na ordem em que aconteceram. Trocar comandos por fatos versionados, gravar antes de aplicar, decidir com uma função que não depende da ordem de chegada, disparar efeitos pela transição de estado e reconciliar periodicamente na fonte transforma a ordem de entrega em detalhe irrelevante. O resultado é uma integração que dá o mesmo estado final qualquer que seja a sequência em que os eventos chegam, e que avisa quando o webhook começa a falhar antes que o cliente perceba.