Skip to main content
Send the same write request twice — during a retry, a timeout, or a crash — and it should count once, returning the same answer both times. For a payment, that means you are charged once. The key that makes this work is request_id.

When you need it

These are two separate keys, one per operation. Reusing one does not carry over to the other. Reads (/status, /timeline) have no side effects, so there’s nothing to deduplicate there.

The two guarantees

1. Gateway deduplication — every write endpoint, every caller

The gateway records your request_id, scoped to your source account and to whoever signed the payment request, so it can’t collide with another account’s. Send the same one again and you get back what it created the first time; nothing new is created. This holds for SDK and direct API callers alike, as long as the request_id is stable across the retry.

2. On-chain single settlement — payments only, SDK-managed

Where funds move (submitPayment), the chain adds a second layer on top of the guarantee above. Each deposit authorization carries a nonce, and a given nonce can settle at most once. The SDK derives that nonce from your request_id automatically, so a retry reuses it without any work on your part. Should you sign your own nonce on each attempt instead, on-chain single settlement is no longer guaranteed. This second layer lasts a different amount of time depending on the chain:
  • EVM / Tron — permanent. A spent nonce can never be reused.
  • Solana — about two minutes. After that, there’s no on-chain protection left — the gateway’s own record of your request_id is what stops a duplicate.

Keys

Three types of identifiers:
Save the key before you send the request. It’s the only thing that connects a retry to its original. If your process restarts and can’t recall the key it used, it starts a second payment.Keep it until the payment reaches a final status, and don’t rely on the gateway remembering it after that. It must also not outlive the purchase — a key left set in config re-attempts the same purchase forever instead of starting the next one. The examples mint a fresh id per run and print it back so you can re-attempt deliberately: PURCHASE_ID=<id> npm run pay.

Reusing a key with different terms is rejected

A request_id locks in one payment (or one quote-request batch). Reuse it with different economics — the accounts, assets, or amounts — and the gateway won’t merge the change, and it won’t quietly hand back the original either. It answers 409 IDEMPOTENCY_TERMS_MISMATCH instead. That covers who pays, in what, up to how much, who receives, in what, and how much they receive. The source spend cap (max_source_amount) is included: it is the amount your signature authorizes, not just your own risk limit, so a retry carrying a different cap is a different payment. Two things are exempt, though — you can freely change these on a retry:
  • Deadlines. quote_deadline and fulfillment_deadline are short-lived on purpose, so they’ve often expired by the time you retry. That’s fine — an expired deadline on a retry isn’t an error, it just resolves onto the request that already exists. On a quote request, note that re-asking with a later quote_deadline does not extend a window already open: the window belongs to the batch, so the response reports the batch’s own deadline rather than the one you just sent.
  • The signature. Re-sign, or resend the original signature — either works. Just keep the request_id itself unchanged.
Accepting payments over x402 or MPP? The same rule binds you: quote the same amount and the same accounts every time you re-issue a challenge for one purchase identifier. Re-price it and the payer’s next attempt is refused with a 409 instead of resuming, and they have no way to act on it.

Telling a retry from a first attempt

Successful payment and quote-request responses include idempotent_replay: true means you got something that already existed, false means this request just created it. Check it whenever you need to tell “this just happened” apart from “this was already there,” which can be useful for logging, reconciliation, or avoiding double counts.

Retrying by status

What each status means is on Payment status. What it means for a retry:
  • pending, finalizing or refunding: still in progress. Keep the same request_id, or poll /status. Do not start a new payment.
  • refund_available: do not pay again. Submit the refund yourself. See Refunds.
  • completed: nothing to do.
  • failed or refunded: final. Sending the same request_id again returns the same payment, so a fresh attempt needs a new request_id. If a failed payment has transactions.deposit set, reconcile first: see A failed payment with a deposit.
Getting pending and a terminal failure the wrong way round is the whole risk here: reusing a key after a terminal failure just returns the same failed payment; minting a new key while a payment is still pending is what causes a double charge.

When a request is refused

An error tells you whether you may send the same request_id again. Read its retry_classification, and its request_id_reusable when present, or look the code up on Error codes. The ones about the key itself: A 503 usually means nothing was accepted, so the same request_id is safe to send again after a delay. The exception is a SERVICE_BUSY that comes with a payment_id: the payment was recorded but could not start, so that request_id is used up and a new attempt needs a new one. request_id_reusable on the error tells you which case you have.

When a payment is still settling

The submit call waits a short time for the result (see Track a payment). A payment that takes longer is answered as pending. That is not a failure.

Direct API, SDK and CLI

Any of these work, and you can mix them:
  • Poll /status with the payment_id — cheapest once you hold one.
  • Resolve the request_id — GET /v1/payments/resolve?request_id=<id> returns the payment_id, for when you lost the submit response but kept your key.
  • Re-submit the same request_id — idempotent, and it returns the payment’s current state. Heaviest of the three, since it needs the full signed request again.

x402 and MPP

The payer re-attempts the same purchase: it fetches a fresh 402, signs it again, and keeps the same purchase identifier. The gateway resolves onto the original payment, so a re-attempt cannot charge twice, however many times it runs. A stored credential is not reused: it is refused once its quote window has closed. A payment that is refund_available is also reported as still settling, so re-attempting never ends: stop and submit the refund. How to spot it: Refunds.
If you’re a merchant mediating someone else’s HTTP request, never poll inside that request. Return what the gateway said and let the payer re-attempt. Polling holds the connection open for the whole settlement; the payer’s own retry doesn’t.

Using it per integration path

requestId is a required parameter on preparePaymentRequest / prepareQuoteRequest. Generate it once per logical payment, store it, and pass the same value on every retry.
send-payment and request-quote accept --request-id. Omit it and the CLI generates one and prints it back — copy that value to retry the same payment. A bare re-run with no flag is a second payment.
The payer supplies a purchase identifier via the paymentIdentifier option, which becomes the request_id:
The wrapped fetch pays once. Re-attempting while the payment is still settling is your code’s job: see When a payment is still settling.
The acceptance side stamps the id into the payment challenge, usually from a value the route already has:
The payer re-attempts by requesting the same resource again: the new challenge carries the same intentId, and the payer signs it again.

If you accept payments: deliver once

The idempotency key stops you from being paid twice. It does not automatically stop you from delivering twice. Always key delivery on Atum’s payment id, which the receipt carries:
  • First successful delivery for that receipt wins.
  • If the payer-side retries and never got the success response, send the same result again — do not ship a second time.
  • In production, that’s usually a column on the payments or orders table you already use for reconciliation.

Common mistakes

  • Minting a new key after a timeout or crash. If the prior attempt might still be in flight, this creates a second payment.
  • Reusing one key across two genuine purchases. The second resolves onto the first — you deliver twice, you’re paid once.
  • Treating a pending payment as failed and retrying with a new key. pending is not failure; retry with the same key or poll /status.
  • Retrying a terminal failure with the same key. Resending that key returns the same failed payment — recovering means a new key, the opposite of the pending case.

See it working

Next steps