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:
| Field | Where it goes | Role |
|---|---|---|
Idempotency-Key | HTTP header | Identifies the attempt. We use it to find the payment already on record and return the same result. |
identifier | Body field | Identifies 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.
| Flow | Deduplicates on | Resending after a failure |
|---|---|---|
| PIX out and PIX refund | identifier + Idempotency-Key | Runs again |
| Internal transfer | identifier + Idempotency-Key | Runs again |
| Boleto payment | Idempotency-Key | Runs again |
| TED | identifier | Does 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:
- Check
GET /v1/accounts/{accountId}/payments/{identifier}or wait for the webhook — the late outcome (pix.out.completedorpix.out.failed) arrives once the partner confirms. - Reconcile against the account statement before issuing a new payment.
- Only use a fresh
Idempotency-Keyafter 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-Keybefore 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
TIMEOUTas 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. TheerrorCodetells you which one it is; the full list lives in Errors.