PayZuDocs
Boas práticas

Idempotência

Explica como usar clientReference para não duplicar cobrança nem baixa quando a chamada se repete.

Idempotência é a garantia de que chamar a mesma operação várias vezes tem o mesmo efeito que chamar uma vez só. Sem isso, retries viram cobranças duplicadas, baixas em dobro e estornos perdidos.

Cenários que exigem idempotência

CenárioSem idempotênciaCom idempotência
Sua app crasha após POST /pix, mas não sabe se chegouGera 2 cobrançasPayZu devolve a existente
POST /pix deu timeout, mas o QR foi geradoCliente vê 2 QRs diferentesPayZu devolve a mesma transação
Job de retry dispara a mesma cobrança 2x2 cobranças, suporte ruim1 cobrança, cliente paga normalmente
Mesmo callback chega 2 vezes (retry após timeout)Marca pedido pago 2xIgnora o duplicado
Transação passa por PENDING → COMPLETED → REFUNDEDPode ignorar o estornoProcessa cada transição uma única vez

clientReference na criação

clientReference é o identificador externo idempotente que você define ao criar uma cobrança, pagamento Pix ou transferência. A PayZu deduplica por conta + clientReference: a mesma chave só colide dentro da sua própria conta, e a transação existente é devolvida se ela já foi criada.

Como gerar

PadrãoQuando usar
order-{orderId}1 cobrança por pedido. Recomendado.
payout-{payoutId}1 pagamento Pix por solicitação.
subscription-{subId}-{period}Cobranças recorrentes (1 por ciclo).
retry-{orderId}-{attempt}Quando você precisa forçar uma nova cobrança após falha definitiva.
transfer-{from}-{to}-{date}Transferências internas idempotentes por dia.

Nunca use Date.now(), uuid() ou outro valor aleatório como clientReference. O retry vai gerar valor diferente e a PayZu vai criar cobrança duplicada, quebrando exatamente a garantia que você queria ter.

curl -X POST https://api.payzu.processamento.com/v1/pix \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 99.90,
    "clientReference": "order-1234",
    "callbackUrl": "https://seusite.com.br/webhooks/payzu"
  }'

O mesmo request em outras linguagens está no tutorial Receber pagamento Pix.

Dedupe de callbacks

O mesmo callback pode chegar mais de uma vez:

A chave de dedupe não pode ser só id: você ignoraria o callback de REFUNDED porque já viu COMPLETED antes, e o estorno não daria baixa. Monte a chave conforme a origem da entrega:

  • Webhook cadastrado: use id mais o cabeçalho X-Callback-Event. Três eventos (TRANSACTION_SUSPECTED_FRAUD, TRANSACTION_SUSPECTED_FRAUD_REVERSAL e INFRACTION_CHANGED) não mudam o status da transação, então id + status descartaria essas entregas como se fossem repetição.
  • callbackUrl da transação: não há cabeçalho de evento, então use id + status. Quando o corpo trouxer o objeto infraction, inclua também infraction.status na chave, senão as atualizações da disputa somem.

Implementação

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_30_DIAS = 30 * 86400;

type PayzuCallback = {
  id: string;
  type: 'DEPOSIT' | 'WITHDRAW';
  method: 'PIX' | 'BANK_SLIP' | 'INTERNAL_TRANSFER';
  status: 'PENDING' | 'COMPLETED' | 'CANCELED' | 'WAITING_FOR_REFUND' | 'REFUNDED' | 'EXPIRED' | 'ERROR';
  clientReference?: string;
};

async function handleCallback(tx: PayzuCallback) {
  const dedupeKey = `payzu:${tx.id}:${tx.status}`;
  const isFirstTime = await redis.set(dedupeKey, '1', 'EX', TTL_30_DIAS, 'NX');
  if (!isFirstTime) return;

  await processTransaction(tx);
}

Armadilhas comuns

ArmadilhaSintoma
clientReference aleatório a cada retryCobrança duplicada, cliente confuso
Dedupe usando só id (sem status)Estorno não dá baixa, refund "fantasma"
TTL do dedupe muito curtoRetry tardio recria processamento
Dedupe em memória (Map local)Após restart, processa tudo de novo
Recriar clientReference com Date.now() por achar que "muda"Não dispara idempotência, gera nova cobrança

Nesta página