Voltar para o Blog
5 minutos de leitura·20 de outubro de 2026

Pagamentos de marketplace: idempotência na fronteira dos webhooks

Como eu estruturei os pagamentos do marketplace da PokerNetic: gateways atrás de uma abstração, webhooks idempotentes, split com a plataforma como merchant of record, ledger do vendedor e moedas internas sem saldo negativo.

EC
Éverton Carvalho
Software Engineer | Software Architect | CTO
Pagamentos de marketplace: idempotência na fronteira dos webhooks

Num marketplace, um webhook repetido não é um caso raro: é o comportamento normal dos gateways, que reenviam até receber uma resposta de sucesso. Se cada reenvio virar um crédito, o vendedor recebe duas vezes; se virar um estorno, perde duas vezes. No marketplace da PokerNetic eu escrevi a maior parte do backend e trabalho no serviço de pagamentos. Aqui conto como a confirmação de pagamento ficou idempotente e o que vem a seguir.

sequenceDiagram
  participant B as Comprador
  participant M as marketplace
  participant Q as RabbitMQ
  participant P as payment
  participant G as Gateway
  participant DB as PostgreSQL
  B->>M: checkout
  M->>Q: pedido de link de pagamento
  Q->>P: gera a sessão no gateway
  P-->>M: link de pagamento (resposta do RPC)
  M-->>B: link de pagamento
  B->>G: paga
  G->>P: webhook assinado
  P->>DB: registra o evento (gateway, id) se ainda não existe
  alt evento repetido
    DB-->>P: já registrado, nada a fazer
  else primeira vez
    P->>Q: pedido pago, na fila do serviço certo
    Q->>M: confirma o pedido
    P->>DB: crédito do vendedor no ledger (gateway interno)
  end

O pedido e o link de pagamento

O checkout recalcula preços e cupons, separa assinaturas de produtos avulsos, gera o número do pedido numa transação com lock e calcula a comissão da plataforma e o valor líquido da loja. Em seguida pede o link de pagamento ao serviço de payment por RPC no RabbitMQ. O payment cria a sessão no gateway e registra a transação com o id do gateway como chave única.

Um pedido não pago expira sozinho: uma fila de jobs agenda a expiração com um id por pedido, e agendar de novo cancela o job anterior do mesmo pedido.

Gateways atrás de uma abstração

Cada gateway implementa a mesma interface: consultar o status, estornar, gerar o link e mapear os status do gateway para os da plataforma, com um padrão seguro para status desconhecidos. O serviço escolhe a implementação por nome, e registrar duas vezes o mesmo nome é erro.

  • Stripe Connect: cobrança direta na conta do vendedor, com a taxa da plataforma descontada na própria cobrança.
  • Gateway interno: a plataforma como merchant of record: todo o valor entra na conta da plataforma, o split vai nos metadados da sessão e é liquidado no ledger do vendedor.
  • Mercado Pago e mock: implementados atrás da mesma interface e hoje desativados: os caminhos ativos são o Stripe Connect e o gateway interno.

Webhooks idempotentes

A assinatura de cada webhook é validada sobre o corpo cru, com um segredo por tipo de integração. A idempotência é um claim no banco: antes de processar, o serviço tenta inserir o par gateway e id do evento numa tabela com índice único. Se a linha já existe, o evento é um reenvio e nada acontece. Se o processamento falha depois do claim, o claim é desfeito e o serviço responde erro, para o gateway reenviar.

Isso nasceu de um problema real: retries de webhook creditavam o vendedor duas vezes e um estorno chegou a debitar duas vezes.

O dinheiro do vendedor

O saldo do vendedor fica num ledger por vendedor e moeda, com saldo pendente e disponível. Créditos e débitos rodam em transação com lock pessimista e cada lançamento guarda o saldo antes e depois, além da data em que o valor fica disponível depois do período de retenção. Payouts seguem a frequência configurada pelo vendedor, com valor mínimo, e as faturas da comissão são geradas por período.

Moedas internas sem saldo negativo

Comprar com as moedas da plataforma segue reservar, aprovar ou liberar: o pedido reserva o valor por RPC, a aprovação transforma a reserva em gasto e uma rejeição devolve. Toda mutação de saldo roda em transação com lock pessimista, e uma constraint de saldo maior ou igual a zero no banco é a última barreira.

A fila que entregava no lugar errado

Um bug ensinou bastante: uma única fila de transações era consumida por quatro serviços diferentes. O RabbitMQ distribui as mensagens em rodízio, sem olhar o tipo, então confirmações de pagamento caíam no serviço errado e eram descartadas em silêncio. A correção foi uma fila de transações por serviço, roteada pelo tipo do pedido, e uma fila legada que avisa quando aparece um tipo sem consumidor.

Na mesma linha, o cliente do RabbitMQ ganhou um listener de erro no canal, porque um erro sem listener na reconexão derrubava o processo, e passou a usar uma conexão compartilhada por processo, com heartbeat e reconexão.

Testes

Os testes cobrem o serviço de idempotência dos webhooks, os handlers de Stripe e do gateway interno, o Stripe Connect, as transações de pagamento, o ledger do vendedor, os payouts e as regras do pedido, do checkout e dos descontos, além do saldo de moedas.

O próximo passo

A garantia mais forte está na fronteira do webhook: um evento nunca é processado duas vezes. O próximo passo é levar a mesma garantia de ponta a ponta para dentro do pedido.

Artigo de autoria própria
Éverton Carvalho|Software Engineer | Software Architect | CTO

Construído com Next.js 15, NestJS 11, PostgreSQL e Prisma. Arquitetura desacoplada e pragmática.

© 2026 Éverton Carvalho. Todos os direitos reservados.