Skip to main content

Idempotency

Idempotency is the guarantee that resending the same request will not create a second payment. In distributed systems the response can be lost after the operation has already settled — a network timeout, a deploy midway — and without idempotency the retry would become a duplicate debit.

Two keys drive this behavior:

FieldWhere it goesRole
Idempotency-KeyHTTP headerIdentifies the attempt. We use it to find the payment already on record and return the same result.
identifierBody fieldIdentifies the operation in your system. We echo it to the settlement partner, and it comes back in every webhook.

Both are optional: when you omit them, we generate a random value. This matters more than it seems — a request without these keys is always treated as a brand new operation, with no protection against duplicates at all. If your retry needs to be safe, send both and repeat the exact same values.

We recommend a UUID v4 for Idempotency-Key and, for identifier, whatever key already identifies the operation on your side (batch number, payment order id).

What each flow deduplicates on

The key that counts varies by flow. Resending with the same values is always safe; what differs is which field you must keep stable.

FlowDeduplicates onResending after a failure
PIX out and PIX refundidentifier + Idempotency-KeyRuns again
Internal transferidentifier + Idempotency-KeyRuns again
Boleto paymentIdempotency-KeyRuns again
TEDidentifierDoes not run; returns the previous attempt's state

Scenarios

The examples use PIX out, but the reasoning holds for internal transfers and boleto payments. TED has its own section because it behaves differently.

The operation succeeded and you resend

We return 200 with the original result — same paymentId, same endToEndId. No new debit happens, and the second request's payload is ignored.

{
"paymentId": "pay_9f2c…",
"status": "COMPLETED",
"endToEndId": "E1234567820260803…",
"identifier": "order-4471"
}

The operation failed definitively and you resend

This covers an account without funds, an invalid PIX key, an anti-fraud rejection, and a block by one of your own policies. The response is 422 with the reason:

{
"paymentId": "pay_9f2c…",
"status": "FAILED",
"errorCode": "insufficient_funds",
"errorReason": "PIX não autorizado por falta de saldo na conta"
}

A definitive failure means the money did not leave the account and never will. Resending with the same Idempotency-Key runs the payment again — which is exactly what you want after topping up the balance or adjusting the rule that blocked it. The paymentId is reused, so you keep tracking the same record instead of having to follow a new one.

Only resend once the cause of the rejection is resolved. Retrying against the same condition returns the same rejection.

The operation is still in flight

While the payment has no outcome yet, resending with the same Idempotency-Key attaches to the run in progress instead of opening a second one. The response is 202 with status: "PENDING". There are never two concurrent payments for the same key.

The outcome is indeterminate (TIMEOUT)

The most delicate case in the API. The partner accepted the submission but did not confirm the outcome within the waiting window: the money may have left the account. We return 202 with status: "TIMEOUT" and a warning.

Behavior deliberately flips here: resending with the same Idempotency-Key returns the existing state and does not submit the payment again, because re-running on top of an unknown outcome is the textbook path to a duplicate payment.

What to do:

  1. Check GET /v1/accounts/{accountId}/payments/{identifier} or wait for the webhook — the late outcome (pix.out.completed or pix.out.failed) arrives once the partner confirms.
  2. Reconcile against the account statement before issuing a new payment.
  3. Only use a fresh Idempotency-Key after confirming the money did not leave.

You resend with a new Idempotency-Key

That is a new operation, and it will create a second payment. Changing the key is how you say "I want to pay again, on purpose". Never generate a new key inside an automatic retry loop.

With one useful caveat: if you change the key but repeat the same identifier, PIX out recognizes the operation and returns 202 without creating a second payment. Treat that as an extra safety net, not a replacement — the strong guarantee against duplicates comes from repeating the same Idempotency-Key.

You resend the same key with a different payload

We do not compare payloads: the key decides. If a payment already exists for that Idempotency-Key, we return its result and ignore the new values — we do not return a conflict error. So never reuse a key for a different operation, or you will get a 200 describing a payment that is not the one you just requested.

TED

TED deduplicates on identifier alone, and never re-runs. Any resend with an identifier already in use returns 200 with that TED's current state, including when that state is a failure:

{
"tedId": "ted-order-4471",
"status": "FAILED",
"identifier": "order-4471",
"note": "TED already exists for this identifier — returning current state (idempotent)"
}

To try again after a failed TED, send a new identifier. This is deliberate: TED has a long settlement window (up to 48h) and identifier is what ties the record to the partner's webhooks.

Expiration

Keys do not expire. The payment record is permanent, so an Idempotency-Key used months ago still returns that operation's result. Do not reuse keys across different operations.

Best practices

  • Generate the Idempotency-Key before the first attempt and persist it alongside the payment order. A key generated inside the retry protects nothing.
  • Keep the same key throughout the whole exponential backoff for network errors and 5xx.
  • Always send identifier, using the id the operation already has in your system. It is the tracing key in webhooks and in support requests.
  • Treat TIMEOUT as its own state, never as a failure. It is the only outcome where reissuing can duplicate money.
  • Before resending after a 422, fix the cause. The errorCode tells you which one it is; the full list lives in Errors.