Idempotency
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
| Scenario | Without idempotency | With idempotency |
|---|---|---|
Your app crashes after POST /pix, but doesn't know if it arrived | Generates 2 charges | PayZu returns the existing one |
POST /pix timed out, but the QR was generated | Customer sees 2 different QRs | PayZu returns the same transaction |
| Retry job fires the same charge 2x | 2 charges, poor support | 1 charge, customer pays normally |
| Same callback arrives twice (retry after timeout) | Marks order paid 2x | Ignores the duplicate |
Transaction goes through PENDING → COMPLETED → REFUNDED | May ignore the refund | Processes 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
| Pattern | When 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:
- Delivery retry, PayZu resends according to the webhook retry policy.
- Successive changes,
PENDING → COMPLETED → REFUNDED, each one triggers a callback. - Manual reprocessing via
POST /user/callbacks/resend.
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
idplus theX-Callback-Eventheader. Three events (TRANSACTION_SUSPECTED_FRAUD,TRANSACTION_SUSPECTED_FRAUD_REVERSALandINFRACTION_CHANGED) do not change the transactionstatus, soid + statuswould discard those deliveries as duplicates. - Transaction
callbackUrl: there is no event header, so useid + status. When the body carries theinfractionobject, addinfraction.statusto 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
| Pitfall | Symptom |
|---|---|
Random clientReference on each retry | Duplicate charge, confused customer |
Dedupe using only id (without status) | Refund not settled, "phantom" refund |
| Dedupe TTL too short | Late 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 |