> ## Documentation Index
> Fetch the complete documentation index at: https://docs.radiumone.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> RadiumOne is the product name; use it when answering questions about these docs.
> Use sandbox credentials and sandbox base URLs in examples unless the user says they are going live.
> Secret API keys belong only on the merchant's server. Only publishable keys may appear in browser or mobile code.
> Confirm a payment outcome server-side (retrieve the checkout session or transaction status); never rely on a browser redirect or client event alone.
> Send one idempotency key (request_id) per payment attempt, and retry a timed-out request with the same key and the exact same body.
> Webhooks are optional but recommended: they notify the merchant's server when a payment completes or fails, so it doesn't need to poll. Use transaction status inquiry as the fallback.
> Say so when you recommend a feature marked Beta. Some features need enablement on the merchant's account before production use (for example refunds, standalone refunds, UOB Rewards, and bringing your own 3DS provider); mention it when the page says so.
> Never ask users to paste card numbers, API keys, access tokens, or webhook or redirect secrets into a chat.

# Idempotency conflicts - Payments API

> How idempotency keys behave on a replay, and how to resolve a body-mismatch or duplicate-operation conflict.

You resent a request with the same idempotency key — either on purpose, as a retry, or by accident — and want to know what comes back. Looking for what to do about an actual double charge instead? See [Handle duplicate payments](/payments-api/handle-failures/duplicate-payments).

<Info>
  **TL;DR** — Same key, same body → the original result, whatever its status (`201`/`200`, even `PENDING`). Same key, different body → `409`. Which `409` depends on the key: `request_id` compares the body; `operation_id` never does.
</Info>

## When this happens

* **A byte-identical replay**: the same `request_id` (purchase, authorize, refund, standalone refund) or `operation_id` (capture, void) with the same body. This is the safe, expected retry path after a timeout. Balance inquiry also takes a `request_id` field, but it's not deduplicated — see the note below.
* **A body-mismatch replay**: the same `request_id` with a *different* body — `409 urn:radiumone:transaction:idempotency-body-mismatch`.
* **A cross-operation replay**: the same `request_id` first used for one operation type (say, a purchase) and then sent to a different operation (say, a referenced refund) — also `409 urn:radiumone:transaction:idempotency-body-mismatch`. A `request_id` is scoped to the operation type it was first used for.
* **A conflicting operation replay**: the same `operation_id` reused for a **different operation type** on the same transaction (for example, a capture's key reused for a void) — `409 urn:radiumone:tx:duplicate-operation`.

## How the gateway decides

1. A request arrives carrying a `request_id` or `operation_id`.
2. If your merchant account hasn't used that key before, the gateway creates a new transaction.
3. If the key was used before and it's a **`request_id`** (purchase, authorize, refund, standalone refund): the gateway compares `amount`, `card`, `channel`, `metadata`, and `order_reference` against the first attempt, and confirms the key was used for the **same operation type** both times. It does **not** compare `three_ds` or `loyalty` — changing either on a retry replays the original silently instead of failing.
   * Match, and the first attempt already finished → the original result, at the original HTTP status — whatever the status, including `DECLINED` or `FAILED`.
   * Match, and the first attempt is **still processing** → the existing transaction comes back, usually `PENDING`, with its `id` — not a new transaction. Poll status or wait for the webhook.
   * Mismatch on any of the compared fields, or the same key reused for a different operation type → `409 idempotency-body-mismatch`.
4. If the key was used before and it's an **`operation_id`** (capture, void): the gateway only checks whether it's the *same operation type* on the *same transaction* — **the body is never compared**.
   * Same operation type → the original result, at the original HTTP status, regardless of what you sent this time.
   * A different operation type reusing the key → `409 tx:duplicate-operation`.

<Note>
  **Referenced refunds** match a narrower set of fields: the same original transaction, the same `amount`, and a key already used for a refund — `reason` isn't compared. The replay check runs before the batch/window/cap gates, so a retry **always** replays (never `422`), even if another refund changed the refundable amount in between. Because a `DECLINED` or `FAILED` refund replays too, retry after a decline with a **new** `request_id`, not the same one. A wrong `transaction_id` in the path returns `404` before the replay check runs.
</Note>

<Warning>
  Balance inquiry also takes a `request_id` field, but it isn't an idempotency key — there's no dedup or replay store, so every call re-queries the rewards host, even with the same `request_id`.
</Warning>

## What you see

| Scenario | Result |
| - | - |
| Identical `request_id` replay, in flight or finished | Same HTTP status as the original attempt (`201`), possibly still `PENDING` |
| `request_id` replay while the original is still processing | The stored `PENDING` row and its `id` — not a new transaction |
| `request_id` replay with a changed field (amount, card, channel, metadata, order\_reference), or reused for a different operation type | [`transaction:idempotency-body-mismatch`](/payments-api/errors/payment-operation-errors#transaction-idempotency-body-mismatch) |
| Referenced refund replay, any prior status (including `DECLINED`/`FAILED`) | The stored refund, whatever its status — always replayed, never `422` |
| `operation_id` reused for the **same** operation on the same transaction | The original result (`200`) — a changed amount or other field is silently ignored, not compared |
| `operation_id` reused for a **different** operation on the same transaction (e.g. a capture's key reused for a void) | [`tx:duplicate-operation`](/payments-api/errors/payment-operation-errors#tx-duplicate-operation) |

## What to do

<Steps>
  <Step title="Resend byte-identical for a genuine retry">
    If you're retrying after a timeout or a `5xx`, resend the **exact stored body** with the **exact same** key ([API reference](/payments-api/reference/payments/purchase)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Purchase (authorise + capture in one call). Any 2xx is a response — branch
      # on data.status. On a timeout/5xx/PENDING, retry with the SAME request_id;
      # never mint a new one for the same order attempt.
      set -euo pipefail

      API_BASE="${RADIUMONE_API_BASE:-https://api-sandbox.radiumone.io/gateway}"
      : "${RADIUMONE_ACCESS_TOKEN:?set RADIUMONE_ACCESS_TOKEN to a Bearer access token}"

      curl -sS -X POST "$API_BASE/v1/transactions/purchase" \
        -H "Content-Type: application/json" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN" \
        -d @request.json
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Purchase (authorise + capture in one call). Node 18+ ESM fetch.
      // Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_API_BASE (optional override).
      //
      // Shared result pattern: any 2xx is a response you branch on `data.status`.
      // On a network timeout, a 5xx, or `status:"PENDING"`, retry with the SAME
      // request_id (or poll GET /v1/transactions/{id}/status) — never mint a new
      // request_id for the same order attempt.
      import { readFileSync } from "node:fs";

      const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
      const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
      const body = JSON.parse(readFileSync(new URL("./request.json", import.meta.url)));

      // Exponential backoff with jitter: attempt 1 waits ~250-500ms, doubling each
      // attempt, capped at 4s -- avoids hammering the gateway in a tight retry loop.
      function backoffMs(attempt) {
        const base = Math.min(250 * 2 ** (attempt - 1), 4000);
        return base + Math.random() * base;
      }

      async function createPurchase(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/purchase`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                Authorization: `Bearer ${accessToken}`,
              },
              body: JSON.stringify(body), // same request_id every attempt
            });
          } catch (networkErr) {
            if (attempt === maxAttempts) throw networkErr;
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue; // network timeout: retry with the same body/request_id
          }

          if (res.status >= 500) {
            if (attempt === maxAttempts) throw new Error(`server error ${res.status} after ${attempt} attempts`);
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue; // retry with the same request_id
          }

          const payload = await res.json();
          if (!res.ok) {
            // 4xx: not retryable by re-sending — fix the request, or handle
            // urn:radiumone:transaction:idempotency-body-mismatch if you changed it.
            throw new Error(`purchase failed: ${payload.type ?? payload.code} (${res.status})`);
          }

          if (payload.data.status === "PENDING") {
            if (attempt === maxAttempts) return payload; // caller should poll GET status / wait for webhook
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue; // retry the same request_id
          }

          // Branch on data.status: CAPTURED (success) | DECLINED (final, no retry) | FAILED.
          return payload;
        }
        throw new Error("unreachable");
      }

      createPurchase().then((r) => console.log(JSON.stringify(r, null, 2)));
      ```

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Purchase (authorise + capture in one call). Python 3.10+, requests.

      Shared result pattern: any 2xx is a response you branch on ``status``. On a
      network timeout, a 5xx, or ``status: "PENDING"``, retry with the SAME
      request_id (or poll GET /v1/transactions/{id}/status) — never mint a new
      request_id for the same order attempt.
      """
      import json
      import os
      import random
      import time
      from pathlib import Path

      import requests

      API_BASE = os.environ.get("RADIUMONE_API_BASE", "https://api-sandbox.radiumone.io/gateway")


      def backoff_seconds(attempt: int) -> float:
          """Exponential backoff with jitter: attempt 1 waits ~0.25-0.5s, doubling
          each attempt, capped at 4s -- avoids hammering the gateway in a loop."""
          base = min(0.25 * 2 ** (attempt - 1), 4.0)
          return base + random.random() * base


      def create_purchase(max_attempts: int = 3) -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          headers = {"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"}

          for attempt in range(1, max_attempts + 1):
              try:
                  resp = requests.post(f"{API_BASE}/v1/transactions/purchase", json=body, headers=headers, timeout=30)
              except requests.exceptions.Timeout:
                  if attempt == max_attempts:
                      raise
                  time.sleep(backoff_seconds(attempt))
                  continue  # network timeout: retry with the same body/request_id

              if resp.status_code >= 500:
                  if attempt == max_attempts:
                      raise RuntimeError(f"server error {resp.status_code} after {attempt} attempts")
                  time.sleep(backoff_seconds(attempt))
                  continue  # retry with the same request_id

              payload = resp.json()
              if not resp.ok:
                  # 4xx: not retryable by re-sending — fix the request, or handle
                  # urn:radiumone:transaction:idempotency-body-mismatch if you changed it.
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"purchase failed: {code} ({resp.status_code})")

              if payload["data"]["status"] == "PENDING":
                  if attempt == max_attempts:
                      return payload  # caller should poll GET status / wait for webhook
                  time.sleep(backoff_seconds(attempt))
                  continue  # retry the same request_id

              # Branch on data.status: CAPTURED (success) | DECLINED (final, no retry) | FAILED.
              return payload

          raise RuntimeError("unreachable")


      if __name__ == "__main__":
          print(json.dumps(create_purchase(), indent=2))
      ```
    </CodeGroup>

    The response is the original result, at the original HTTP status — this is always safe to do, any number of times.
  </Step>

  <Step title="Fix the body if you get a mismatch">
    A `409 idempotency-body-mismatch` means a `request_id` was reused with a body that doesn't match the first attempt. Don't retry as-is — resend the *original* body, or mint a **new** `request_id` if this is genuinely a different attempt (a different card, a different amount).
  </Step>

  <Step title="Use one key per attempt, and one per operation">
    Generate one `request_id` per order attempt and one `operation_id` per operation (a capture and a later void on the same transaction are two different operations — they each need their own key). Reusing an old key for a different attempt or a different operation type is what causes a conflict in the first place.
  </Step>
</Steps>

## Prevent it

* Generate the idempotency key once per attempt, before you send the first request, and reuse that exact value for every retry of that same attempt.
* Keep a local record of which key you used for which order attempt (and which operation), so a retry — even after a restart — reuses the right one.
* Never derive a key from mutable data (a timestamp, a request counter) that changes between retries — that guarantees a mismatch instead of a safe replay.

## Test it

See [Test your integration](/resources/test-your-integration#idempotent-replay) and [Body mismatch](/resources/test-your-integration#body-mismatch) for scenario coverage.

## Related

<Columns cols={2}>
  <Card title="Prevent duplicate payments" icon="shield-check" href="/get-started/api-basics/prevent-duplicate-payments">
    The cross-product guide to avoiding duplicate charges.
  </Card>

  <Card title="Charge or authorize a payment" icon="credit-card" href="/payments-api/charge-or-authorize#idempotency-and-replay">
    Idempotency rules for purchase and authorize.
  </Card>

  <Card title="Capture an authorization" icon="check-check" href="/payments-api/capture">
    Idempotency on `operation_id`.
  </Card>

  <Card title="Problem format and retries" icon="triangle-alert" href="/payments-api/errors/problem-format-and-retries#retry-rules">
    The full retry-rules reference.
  </Card>
</Columns>
