# Idempotency (/en/docs/pix-processamento/best-practices/idempotency)

<QuickLinks>
  <QuickLink href="/docs/pix-processamento/endpoints/pix-operations/post_pix" title="POST /pix" />

  <QuickLink href="/docs/pix-processamento/endpoints/pix-operations/get_pix" title="GET /pix" />

  <QuickLink href="/docs/pix-processamento/webhooks" title="Webhooks" />
</QuickLinks>

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 [#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-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.

<Mermaid
  chart="`
flowchart LR
  A[&#x22;POST /pix&#x22;]
  A -->|&#x22;1st call&#x22;| B[&#x22;Creates new&#x22;]
  A -->|&#x22;Retry&#x22;| C[&#x22;Returns the existing one&#x22;]

  click A &#x22;/en/docs/pix-processamento/endpoints/pix-operations/post_pix&#x22; &#x22;POST /pix&#x22;

  style B fill:#14ce71,stroke:#0eb464,color:#ffffff
  style C fill:#14ce71,stroke:#0eb464,color:#ffffff
`"
/>

### How to generate [#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.                              |

<Callout type="warn">
  **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.
</Callout>

```bash
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](/docs/pix-processamento/tutoriais/receive-pix) tutorial.

## Callback dedupe [#callback-dedupe]

The same callback can arrive more than once:

* **Delivery retry**, PayZu resends according to the [webhook retry policy](/docs/pix-processamento/webhooks#retry-system).
* **Successive changes**, `PENDING → COMPLETED → REFUNDED`, each one triggers a callback.
* **Manual reprocessing** via [`POST /user/callbacks/resend`](/docs/pix-processamento/endpoints/callbacks/resend_user_callbacks).

The dedupe key &#x2A;*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.

<Mermaid
  chart="`
flowchart LR
  A[&#x22;1st arrival COMPLETED&#x22;] -->|&#x22;unique key&#x22;| K1[&#x22;Processes&#x22;]
  B[&#x22;Retry COMPLETED&#x22;] -->|&#x22;same key&#x22;| K2[&#x22;Ignores&#x22;]
  C[&#x22;Change to REFUNDED&#x22;] -->|&#x22;new key&#x22;| K3[&#x22;Processes refund&#x22;]

  click A &#x22;/en/docs/pix-processamento/webhooks&#x22; &#x22;Webhooks&#x22;
  click B &#x22;/en/docs/pix-processamento/webhooks#retry-system&#x22; &#x22;Retry&#x22;
  click C &#x22;/en/docs/pix-processamento/med&#x22; &#x22;MED refund&#x22;

  style K1 fill:#14ce71,stroke:#0eb464,color:#ffffff
  style K2 fill:#737373,stroke:#525252,color:#ffffff
  style K3 fill:#ef4444,stroke:#dc2626,color:#ffffff
`"
/>

### Implementation [#implementation]

```ts
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 [#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 |