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

# Void and refund conflicts - Payments API

> Which operation applies once a settlement batch closes, and how to resolve a conflict.

You called void or refund on the wrong side of the settlement batch boundary, tried to return more than remains, or tried to touch a transaction that's no longer eligible.

<Info>
  **TL;DR** — Void while the batch is **open**, refund once it's **closed**. `422 tx:amount-exceeds-captured` means you asked for more than remains (original minus prior refunds).
</Info>

## When this happens

<Note>
  **Void while the settlement batch is OPEN. Refund once it's CLOSED.** You can't do either the other way around:

  * Voiding a transaction whose batch has already closed returns `409 urn:radiumone:tx:void-on-non-open-batch`.
  * Refunding a transaction whose batch is still open returns `409 urn:radiumone:tx:refund-on-open-batch`.

  The boundary is the settlement **batch** closing, not the card network settling with the issuer. Check `GET /v1/transactions/{id}/status` or a `settlement.*` webhook if you're unsure which state you're in. See [Resolve void and refund conflicts](/payments-api/handle-failures/void-and-refund-conflicts) if you hit either error.
</Note>

* Void after the batch closed — `409 urn:radiumone:tx:void-on-non-open-batch`.
* Refund while the batch is still open — `409 urn:radiumone:tx:refund-on-open-batch`.
* Void while an earlier void on the same sale hasn't resolved yet — `409 urn:radiumone:transaction:void-reversal-pending`. It's transient: check the transaction status for the earlier void's outcome, and void again later only if the sale still isn't voided.
* Refund `amount` exceeds what's left (original minus prior refunds) — `422 urn:radiumone:tx:amount-exceeds-captured`.
* Refund attempted after the refund window has elapsed since capture/settlement — `422 urn:radiumone:tx:refund-window-expired`.
* The target transaction isn't in a refundable state (already refunded in full, voided, or otherwise) — `422 urn:radiumone:tx:refund-target-not-refundable`.
* The transaction's state doesn't allow the operation you requested at all — `409 urn:radiumone:tx:invalid-state-transition`.

## What you see

| Signal | Value |
| - | - |
| HTTP status | `409` (batch-state, pending-void, and invalid-state-transition conflicts) or `422` (amount/window conflicts) |
| Error type | `tx:void-on-non-open-batch` / `tx:refund-on-open-batch` / `transaction:void-reversal-pending` / `tx:invalid-state-transition` / `tx:amount-exceeds-captured` / `tx:refund-window-expired` / `tx:refund-target-not-refundable` |

## What to do

<Steps>
  <Step title="Check the batch state before you pick void or refund">
    Check the transaction's status ([API reference](/payments-api/reference/transactions/transaction-status-inquiry)) or wait for a `settlement.*` webhook to know which side of the boundary you're on, rather than guessing.
  </Step>

  <Step title="While the batch is open, void">
    ([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">
    Cap the amount at the remaining balance (original minus prior refunds) ([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>
  </Step>

  <Step title="Past the refund window">
    If your account has a configured refund window and it has elapsed, a referenced refund is no longer available — use [Standalone refunds](/payments-api/standalone-refunds) if it's enabled for your outlet, or contact [support](/resources/support).
  </Step>
</Steps>

## Related

<Columns cols={2}>
  <Card title="Void a payment" icon="rotate-ccw" href="/payments-api/void">
    Void while the batch is open.
  </Card>

  <Card title="Refund a payment" icon="undo-2" href="/payments-api/refund">
    Refund once the batch is closed, capped at the remaining amount.
  </Card>

  <Card title="Standalone refunds" icon="triangle-alert" href="/payments-api/standalone-refunds">
    High-risk fallback when a referenced refund is no longer available.
  </Card>

  <Card title="Payment operation errors" icon="triangle-alert" href="/payments-api/errors/payment-operation-errors#transaction-state-capture-void-refund">
    The full URN catalog for transaction-state errors.
  </Card>
</Columns>
