Processar eventos sem duplicar

Processe cada evento uma única vez, mesmo quando ele chega repetido, fora de ordem ou com um status posterior.

Por que o mesmo evento chega mais de uma vez

Conte com repetições. O mesmo evento pode chegar de novo quando:

  • o seu servidor demorou mais de 10 segundos para responder. A PagPolar conta como falha e tenta de novo, mesmo que você já tenha processado;
  • alguém reenviou os avisos pelo painel, um por um ou em massa;
  • o gateway avisou o pagamento por mais de um caminho. TRANSACTION_PAID pode ser gerado mais de uma vez para a mesma venda.

Envios de teste

Os envios do Playground chegam com "test": true no envelope e o header X-PagPolar-Test: true. Os eventos reais nunca trazem esses dois. Em produção, descarte o evento de teste antes de processar.

A ordem não é garantida

Cada evento é entregue separado, e vários são entregues ao mesmo tempo. Uma entrega que falha volta cerca de 30 segundos depois. Por isso, um TRANSACTION_CREATED que falhou pode chegar depois do TRANSACTION_PAID da mesma venda.

O id do envelope não serve para descartar repetidos

Cada tentativa chega com um id novo e uma creation_date nova. Duas tentativas do mesmo evento têm id diferentes. Não use o id para descobrir se o evento é repetido.

Monte a sua chave de duplicidade

Use o nome do evento e o id da venda ou da assinatura:

EventoChave sugerida
TRANSACTION_ASK_REFUNDINGNão descarte pela chave. A mesma venda pode receber um novo pedido depois que o anterior terminou. Confira em GET /refunds?sale_identifier=<CODIGO_DA_VENDA> se o pedido é novo.
Outros eventos de venda (TRANSACTION_*)event + data.transaction.id
SUBSCRIPTION_RENEWEDevent + data.subscription.id + data.subscription.next_billing_at
Outros eventos de assinatura (SUBSCRIPTION_*)event + data.subscription.id

Cuidados com esta tabela:

  • Renovações têm id próprio. Cada cobrança de renovação é uma venda nova, com outro transaction.id. A chave dos eventos de venda funciona para todos os ciclos.
  • SUBSCRIPTION_RENEWED chega uma vez por ciclo pago, sempre com o mesmo subscription.id. Por isso a chave inclui next_billing_at, que muda a cada ciclo.
  • SUBSCRIPTION_DELAYED pode chegar uma vez por dia enquanto a assinatura está atrasada. Com a chave sugerida, você trata só o primeiro aviso. Se quiser lembrar o cliente todo dia, não descarte esse evento.

Grave a chave numa tabela com índice único. Se a gravação falhar por chave repetida, o evento já foi processado.

Responda rápido e processe depois

A resposta precisa chegar dentro do tempo limite. Não faça trabalho pesado antes de responder:

  1. Confira o header Authorization.
  2. Grave o evento recebido.
  3. Responda 200.
  4. Processe o evento em seguida, fora da requisição.

Não deixe o status voltar para trás

O data traz o estado do momento do envio, que pode ser mais antigo do que o que você já gravou. Antes de mudar o seu pedido:

  • compare o status recebido com o que você já gravou;
  • não volte um pedido pago para pendente por causa de um evento que chegou atrasado;
  • se tiver dúvida, consulte o estado atual em GET /sales/{identifier} ou GET /subscriptions/{id}.

O fluxo completo

Este fluxograma é uma recomendação para o seu servidor.

Exemplo em Node.js

O exemplo usa um Set na memória para ficar curto. Em produção, troque o Set por uma tabela no banco com índice único na chave.

import express from 'express';
import { timingSafeEqual } from 'node:crypto';

const app = express();
app.use(express.json());

const expectedAuthorization = Buffer.from(
  `Bearer ${process.env.PAGPOLAR_WEBHOOK_TOKEN}`,
);
const processedKeys = new Set();

const isFromPagPolar = (request) => {
  const receivedAuthorization = Buffer.from(request.get('authorization') ?? '');

  return (
    receivedAuthorization.length === expectedAuthorization.length &&
    timingSafeEqual(receivedAuthorization, expectedAuthorization)
  );
};

const buildDeduplicationKey = ({ event, data }) => {
  if (event === 'TRANSACTION_ASK_REFUNDING') {
    return null;
  }

  if (data.transaction) {
    return `${event}:${data.transaction.id}`;
  }

  if (event === 'SUBSCRIPTION_RENEWED') {
    return `${event}:${data.subscription.id}:${data.subscription.next_billing_at}`;
  }

  return `${event}:${data.subscription.id}`;
};

const handleEvent = async (payload) => {
  console.log('Processando', payload.event, payload.data.transaction?.status);
};

app.post('/webhooks/pagpolar', (request, response) => {
  if (!isFromPagPolar(request)) {
    return response.sendStatus(401);
  }

  const payload = request.body;
  response.sendStatus(200);

  if (!payload.data?.transaction && !payload.data?.subscription) {
    return;
  }

  const deduplicationKey = buildDeduplicationKey(payload);

  if (!deduplicationKey) {
    handleEvent(payload).catch((error) => {
      console.error('Falha ao processar', payload.event, error);
    });
    return;
  }

  if (processedKeys.has(deduplicationKey)) {
    return;
  }

  processedKeys.add(deduplicationKey);

  handleEvent(payload).catch((error) => {
    processedKeys.delete(deduplicationKey);
    console.error('Falha ao processar', deduplicationKey, error);
  });
});

app.listen(3000);

Se o processamento falhar, o exemplo apaga a chave. Assim, um reenvio do mesmo evento é processado de novo.

TRANSACTION_ASK_REFUNDING fica sem chave no exemplo. Dentro de handleEvent, confira em GET /refunds se o pedido é novo.

Próximos passos