PayZuDocs
Best practices

Explains how to use clientReference so a repeated call does not duplicate a charge or a settlement.

Idempotency is the guarantee that calling the same operation multiple times has the same effect as calling it once. Without it, retries turn into duplicate charges, double settlements, and lost refunds.

Scenarios that require idempotency

ScenarioWithout idempotencyWith idempotency
Your app crashes after POST /pix, but doesn't know if it arrivedGenerates 2 chargesPayZu returns the existing one
POST /pix timed out, but the QR was generatedCustomer sees 2 different QRsPayZu returns the same transaction
Retry job fires the same charge 2x2 charges, poor support1 charge, customer pays normally
Same callback arrives twice (retry after timeout)Marks order paid 2xIgnores the duplicate
Transaction goes through PENDING → COMPLETED → REFUNDEDMay ignore the refundProcesses each transition only once

clientReference on creation

clientReference is the idempotent external identifier that you define when creating a charge, Pix payment, or transfer. PayZu deduplicates by account + clientReference: the same key only collides within your own account, and the existing transaction is returned if it was already created.

How to generate

PatternWhen to use
order-{orderId}1 charge per order. Recommended.
payout-{payoutId}1 payout per request.
subscription-{subId}-{period}Recurring charges (1 per cycle).
retry-{orderId}-{attempt}When you need to force a new charge after a definitive failure.
transfer-{from}-{to}-{date}Internal transfers idempotent per day.

Never use Date.now(), uuid(), or any other random value as clientReference. The retry will generate a different value and PayZu will create a duplicate charge, breaking exactly the guarantee you wanted to have.

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://yoursite.com/webhooks/payzu"
  }'

The same request in other languages is in the Receive Pix payment tutorial.

Callback dedupe

The same callback can arrive more than once:

The dedupe key cannot be just id: you would ignore the REFUNDED callback because you already saw COMPLETED before, and the refund would not be settled. Build the key according to where the delivery came from:

  • Registered webhook: use id plus the X-Callback-Event header. Three events (TRANSACTION_SUSPECTED_FRAUD, TRANSACTION_SUSPECTED_FRAUD_REVERSAL and INFRACTION_CHANGED) do not change the transaction status, so id + status would discard those deliveries as duplicates.
  • Transaction callbackUrl: there is no event header, so use id + status. When the body carries the infraction object, add infraction.status to the key as well, otherwise dispute updates disappear.

Implementation

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_30_DAYS = 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_DAYS, 'NX');
  if (!isFirstTime) return;

  await processTransaction(tx);
}

Common pitfalls

PitfallSymptom
Random clientReference on each retryDuplicate charge, confused customer
Dedupe using only id (without status)Refund not settled, "phantom" refund
Dedupe TTL too shortLate retry recreates processing
In-memory dedupe (local Map)After restart, processes everything again
Recreating clientReference with Date.now() thinking it "changes"Doesn't trigger idempotency, generates new charge

On this page