> ## 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.

# Duplicate payments - Payments API

> What to do when you see two charges for one order, and how to stop your retry path from creating a second one.

Two transactions exist for what should have been one order — a shopper says they were charged twice, or your own reconciliation shows two successful payments against the same order.

<Info>
  **TL;DR** — Two real transactions with **different** `request_id`s both succeeded — that's a genuine duplicate, not a replay. Void the extra one while its settlement batch is still open, or refund it once the batch closes.
</Info>

## When this happens

* Your server retried a purchase or authorize call after a timeout or network error, but generated a **new** `request_id` for the retry instead of reusing the one from the first attempt. RadiumOne has no way to know the two calls were the same attempt, so both go through as separate, legitimate transactions.
* A double-click, a page refresh, or a resubmitted form reaches your server twice, and your server builds a fresh payment call — with its own `request_id` — each time, instead of recognizing it's the same attempt.
* You're integrating through Hosted checkout or Elements and the duplicate started upstream of the Payments API call itself — see [Prevent duplicate sessions and double payments](/hosted-checkout/handle-failures/duplicate-sessions-and-double-submit) or [Prevent double submission](/elements/handle-failures/double-submit).

<Note>
  This is different from an [idempotency conflict](/payments-api/handle-failures/idempotent-replays-and-conflicts). Idempotency is the *mechanism* that stops a **repeated** `request_id`/`operation_id` from charging twice; a duplicate payment is the *problem* it exists to prevent. It shows up here specifically because the same attempt got two **different** keys, so nothing on RadiumOne's side ever saw them as the same request. A `409 idempotency-body-mismatch` or `409 tx:duplicate-operation` is actually the safe outcome — it means a reused key was caught before a second charge went through. See that page for what those responses mean and how to resolve them.
</Note>

## What you see

* Two transactions with different `id`s and different `request_id`s, both `AUTHORIZED` or `CAPTURED`, for the same order.
* Two separate webhook deliveries — `payment.captured` or `authorization.created` twice, each with a different event `id` **and** a different `data.transaction.id`. That's not the same as one event delivered twice: a repeated event `id` for the *same* transaction is a redelivery, not a duplicate charge — see [Recover from missed, duplicate or out-of-order webhooks](/payments-api/handle-failures/missed-duplicate-or-out-of-order-webhooks).

## What to do

<Steps>
  <Step title="Confirm it's a real duplicate, not a replay or a redelivery">
    Check both transaction `id`s ([API reference](/payments-api/reference/transactions/transaction-status-inquiry)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Live acquirer status inquiry for a transaction (no new row written). Use
      # this to recover after a timeout instead of guessing the outcome.
      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}"
      : "${RADIUMONE_TRANSACTION_ID:?set RADIUMONE_TRANSACTION_ID to the transaction to inquire}"

      curl -sS "$API_BASE/v1/transactions/$RADIUMONE_TRANSACTION_ID/status" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN"
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Live acquirer status inquiry for a transaction (no new row written). Use
      // this to recover after a timeout instead of guessing the outcome. Node 18+
      // ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_TRANSACTION_ID, RADIUMONE_API_BASE.
      const API_BASE = process.env.RADIUMONE_API_BASE || "https://api-sandbox.radiumone.io/gateway";
      const accessToken = process.env.RADIUMONE_ACCESS_TOKEN;
      const transactionId = process.env.RADIUMONE_TRANSACTION_ID;

      async function inquireTransactionStatus() {
        const res = await fetch(`${API_BASE}/v1/transactions/${transactionId}/status`, {
          headers: { Authorization: `Bearer ${accessToken}` },
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`status inquiry failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        return payload; // branch on data.status
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Live acquirer status inquiry for a transaction (no new row written). Use
      this to recover after a timeout instead of guessing the outcome.
      """
      import json
      import os

      import requests

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


      def inquire_transaction_status() -> dict:
          transaction_id = os.environ["RADIUMONE_TRANSACTION_ID"]
          resp = requests.get(
              f"{API_BASE}/v1/transactions/{transaction_id}/status",
              headers={"Authorization": f"Bearer {os.environ.get('RADIUMONE_ACCESS_TOKEN', '')}"},
              timeout=30,
          )
          payload = resp.json()
          if not resp.ok:
              code = payload.get("type") or payload.get("code")
              raise RuntimeError(f"status inquiry failed: {code} ({resp.status_code})")
          return payload  # branch on data.status


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

    Two distinct `id`s, each with its own `request_id`, both successful — that's a genuine duplicate. The same `id` reported twice is a webhook redelivery, not a double charge; see [Recover from missed, duplicate or out-of-order webhooks](/payments-api/handle-failures/missed-duplicate-or-out-of-order-webhooks) instead.
  </Step>

  <Step title="While the batch is still open, void the extra transaction">
    ([API reference](/payments-api/reference/payments/void-a-pre-settlement-transaction))

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Void while the settlement batch is still OPEN (use refund once it closes —
      # see snippets/shared/void-or-refund-batch-rule.mdx). Retry the SAME
      # operation_id on a timeout/5xx.
      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}"
      : "${RADIUMONE_TRANSACTION_ID:?set RADIUMONE_TRANSACTION_ID to the transaction to void}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Void while the settlement batch is still OPEN (use refund once it closes —
      // see snippets/shared/void-or-refund-batch-rule.mdx). Node 18+ ESM fetch.
      // Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_TRANSACTION_ID, RADIUMONE_API_BASE.
      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 transactionId = process.env.RADIUMONE_TRANSACTION_ID;
      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 voidTransaction(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/${transactionId}/void`, {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                Authorization: `Bearer ${accessToken}`,
              },
              body: JSON.stringify(body), // same operation_id every attempt
            });
          } catch (networkErr) {
            if (attempt === maxAttempts) throw networkErr;
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue;
          }

          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;
          }

          const payload = await res.json();
          if (!res.ok) {
            // 409 tx:void-on-non-open-batch — the batch already closed; refund instead.
            throw new Error(`void failed: ${payload.type ?? payload.code} (${res.status})`);
          }
          return payload;
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Void while the settlement batch is still OPEN (use refund once it closes —
      see snippets/shared/void-or-refund-batch-rule.mdx).
      """
      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 void_transaction(max_attempts: int = 3) -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          transaction_id = os.environ["RADIUMONE_TRANSACTION_ID"]
          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/{transaction_id}/void", json=body, headers=headers, timeout=30
                  )
              except requests.exceptions.Timeout:
                  if attempt == max_attempts:
                      raise
                  time.sleep(backoff_seconds(attempt))
                  continue

              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

              payload = resp.json()
              if not resp.ok:
                  # 409 tx:void-on-non-open-batch — the batch already closed; refund instead.
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"void failed: {code} ({resp.status_code})")
              return payload

          raise RuntimeError("unreachable")


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

  <Step title="Once the batch has closed, refund the extra transaction instead">
    ([API reference](/payments-api/reference/refunds/refund-against-a-settled-transaction))

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Refund against a settled transaction (full or partial, repeatable up to the
      # original amount). Only once its batch has CLOSED — see
      # snippets/shared/void-or-refund-batch-rule.mdx. Retry the SAME request_id on
      # a timeout/5xx.
      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}"
      : "${RADIUMONE_TRANSACTION_ID:?set RADIUMONE_TRANSACTION_ID to the CAPTURED transaction to refund}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Refund against a settled transaction, only once its batch has CLOSED — see
      // snippets/shared/void-or-refund-batch-rule.mdx. Node 18+ ESM fetch.
      // Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_TRANSACTION_ID, RADIUMONE_API_BASE.
      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 transactionId = process.env.RADIUMONE_TRANSACTION_ID;
      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 refundTransaction(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/refund/${transactionId}`, {
              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;
          }

          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;
          }

          const payload = await res.json();
          if (!res.ok) {
            // 409 tx:refund-on-open-batch — the batch is still open; void instead.
            throw new Error(`refund failed: ${payload.type ?? payload.code} (${res.status})`);
          }
          return payload;
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Refund against a settled transaction, only once its batch has CLOSED — see
      snippets/shared/void-or-refund-batch-rule.mdx.
      """
      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 refund_transaction(max_attempts: int = 3) -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          transaction_id = os.environ["RADIUMONE_TRANSACTION_ID"]
          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/refund/{transaction_id}", json=body, headers=headers, timeout=30
                  )
              except requests.exceptions.Timeout:
                  if attempt == max_attempts:
                      raise
                  time.sleep(backoff_seconds(attempt))
                  continue

              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

              payload = resp.json()
              if not resp.ok:
                  # 409 tx:refund-on-open-batch — the batch is still open; void instead.
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"refund failed: {code} ({resp.status_code})")
              return payload

          raise RuntimeError("unreachable")


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

    See [Resolve void and refund conflicts](/payments-api/handle-failures/void-and-refund-conflicts) if you're not sure which side of the batch boundary you're on.
  </Step>

  <Step title="Fix the retry path so it doesn't happen again">
    Persist the `request_id` before you send the first call, and reuse that exact value on every retry of the same attempt — see Prevent it below.
  </Step>
</Steps>

## Prevent it

* Generate one `request_id` per payment attempt, save it to your database **before** you send the request, and reuse the exact same value for every retry of that attempt — never mint a new one just because the first call was slow or failed.
* Give capture and void each their own `operation_id`. They're different operations on the same transaction, so reusing one operation's key for the other returns `409 tx:duplicate-operation` instead of doing what you meant — it doesn't create a second charge, but it does mean your retry logic needs its own key per operation type.
* If a call times out or fails without a response, resend it with the **same** key — a replay returns the original result instead of charging again, so it's always safe (see [Handle timeouts and unknown outcomes](/payments-api/handle-failures/timeouts-and-unknown-outcomes)). Start a new attempt with a new key only once you know the first one didn't succeed — from the confirming webhook (recommended) or [status inquiry](/payments-api/check-transaction-status).
* Disable your pay action for the whole attempt, not just the network call, so a double-click can't reach your server twice with two different keys.

See [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments) for the full cross-product pattern, including the checklist and anti-patterns table.

## Test it

See [Test your integration: idempotent replay](/resources/test-your-integration#idempotent-replay) and [body mismatch](/resources/test-your-integration#body-mismatch) to confirm your retry path reuses the key correctly before you go live.

## Related

<Columns cols={2}>
  <Card title="Handle failures" icon="triangle-alert" href="/payments-api/handle-failures/overview">
    Back to every Payments API failure scenario.
  </Card>

  <Card title="Handle replays and idempotency conflicts" icon="repeat" href="/payments-api/handle-failures/idempotent-replays-and-conflicts">
    What happens when a key is reused correctly — or incorrectly.
  </Card>

  <Card title="Prevent duplicate payments" icon="shield-check" href="/get-started/api-basics/prevent-duplicate-payments">
    The cross-product guide, checklist, and anti-patterns.
  </Card>

  <Card title="Resolve void and refund conflicts" icon="undo-2" href="/payments-api/handle-failures/void-and-refund-conflicts">
    Void vs refund, and the batch-boundary rule.
  </Card>
</Columns>
