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 signing identity. 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.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

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.

Reading the outcome (payments)

completed and failed are the only statuses that end a payment. finalizing is published so that a client handles it before the gateway starts sending it. No service returns it today, so treating it as unreachable is safe, but a caller that branches exhaustively on status should add it now rather than discover it later. 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 dead payment forever; minting a new key while a payment is still pending is what causes a double charge.

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:
If the acceptance side reports settlement_pending, your code has to re-attempt the same purchase (fresh 402, re-signed credential) until settlement reaches a terminal outcome — the wrapped fetch does not do this for you.
The acceptance side stamps the id into the payment challenge, usually from a value the route already has:

Checking on a pending payment

Any of these work, and you can mix them:
  • Poll /status with the payment_id — cheapest once you hold one.
  • Resolve the request_idGET /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.
If you’re a merchant mediating someone else’s HTTP request (x402/MPP), prefer having the payer re-attempt over polling inside that request. Polling holds the connection open for the whole settlement window; the payer’s own retry doesn’t.

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 receipt id (payment_id):
  • 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. A failed payment keeps its key forever — recovering means a new key, the opposite of the pending case.

See it working

Next steps