Idempotência
Chamar a mesma operação de novo não deveria cobrar duas vezes nem dar baixa em dobro: veja como usar a sua referência para amarrar isso e por que os callbacks chegam repetidos.
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ário | Sem idempotência | Com idempotência |
|---|---|---|
Sua app crasha após POST /pix, mas não sabe se chegou | Gera 2 cobranças | PayZu devolve a existente |
POST /pix deu timeout, mas o QR foi gerado | Cliente vê 2 QRs diferentes | PayZu devolve a mesma transação |
| Job de retry dispara a mesma cobrança 2x | 2 cobranças, suporte ruim | 1 cobrança, cliente paga normalmente |
| Mesmo callback chega 2 vezes (retry após timeout) | Marca pedido pago 2x | Ignora o duplicado |
Transação passa por PENDING → COMPLETED → REFUNDED | Pode ignorar o estorno | Processa cada transição uma única vez |
clientReference na criação
clientReference é o identificador externo idempotente que você define ao criar uma cobrança, saque ou transferência. A PayZu indexa por userId + clientReference e devolve a transação existente se ela já foi criada.
Como gerar
| Padrão | Quando usar |
|---|---|
order-{orderId} | 1 cobrança por pedido. Recomendado. |
payout-{payoutId} | 1 saque 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 por id + status
O mesmo callback pode chegar mais de uma vez:
- Retry de entrega, a PayZu reenvia conforme a política de retry dos webhooks.
- Mudanças sucessivas,
PENDING → COMPLETED → REFUNDED, cada uma gera callback. - Reprocessamento manual via
POST /user/callbacks/resend.
A chave de dedupe deve ser id + status, não só id. Se você usar só id, vai ignorar o callback de REFUNDED porque já viu COMPLETED antes, e estorno não dá baixa.
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
| Armadilha | Sintoma |
|---|---|
clientReference aleatório a cada retry | Cobrança duplicada, cliente confuso |
Dedupe usando só id (sem status) | Estorno não dá baixa, refund "fantasma" |
| TTL do dedupe muito curto | Retry 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 |
Boas práticas
O que separa uma integração que aguenta produção de uma que quebra na primeira semana: idempotência, callbacks, dinheiro em reais, segurança e os demais padrões que evitam cobrança duplicada e callback perdido quando o volume cresce.
Multi-tenant com virtualAccount
Uma conta PayZu só atende várias lojas, filiais ou marcas: marque cada transação com o identificador do tenant e ele volta em todo callback e serve de filtro nas listagens, sem precisar abrir contas separadas.