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

# Refund a payment - Payments API

> Return all or part of a captured payment after its settlement batch closes.

export const RequiresEnablement = ({feature}) => <Note>
    {feature ? <><strong>{feature}</strong> requires</> : "This feature requires"} enablement on your account before you can use it in production. See <a href="/resources/support#request-enablement">Request enablement</a>.
  </Note>;

A **referenced** refund returns money against a specific prior transaction, once its settlement batch has closed. It's the normal way to refund a payment you (or the shopper) originated.

## Refunds require enablement

<Warning>
  Unlike most gateways, refunds are **disabled by default** on RadiumOne. This applies to both a referenced refund (this page) and a [standalone refund](/payments-api/standalone-refunds) — the acquirer channel your outlet routes through must have the `REFUND` operation explicitly enabled before either call succeeds. Without it, a referenced refund fails with `422 urn:radiumone:routing:capability-not-supported`.
</Warning>

<RequiresEnablement feature="Refunds" />

<Info>
  This request requires a valid access token. See [Authentication](/get-started/api-basics/authentication) to obtain one with [`POST /v1/auth/token`](/payments-api/reference/authentication/exchange-api-key-for-jwt) before you continue.
</Info>

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

## Partial and multiple refunds

Send an `amount` up to the original transaction's amount minus any prior refunds already issued against it. You can refund the same transaction more than once — for example, refunding one line item now and another later — as long as the running total never exceeds the original amount.

<Info>
  Amounts are always integers in the currency's minor unit. For example, `5000` for `SGD` means SGD 50.00.
</Info>

## Refund is a new transaction

A referenced refund creates its **own** transaction record with its own `id` and its own `CAPTURED` status on success — it doesn't change the status of the original payment. Look up the refund by the `id` returned in the response, not by the original transaction's `id`.

## Steps

<Steps>
  <Step title="Refund the transaction">
    Send the original transaction's `id`, a `request_id` for this refund attempt, and the `amount` to return. [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>

    If the call times out, see [Handle timeouts and unknown outcomes](/payments-api/handle-failures/timeouts-and-unknown-outcomes). If it conflicts with the batch state or the remaining amount, see [Resolve void and refund conflicts](/payments-api/handle-failures/void-and-refund-conflicts).
  </Step>
</Steps>

## Handle the result

**Any 2xx response is a result you must branch on `status`** — never on `response_code` (that's the verbatim host/acquirer code; useful for support tickets, not for your app logic).

| Status | Meaning | What to do |
| - | - | - |
| `AUTHORIZED` | Funds reserved (authorize only) | Capture within the capture window, or void to release |
| `CAPTURED` | Funds captured (purchase, capture, or refund) | Fulfil the order (or process the refund) |
| `VOIDED` | Authorization released | No funds moved |
| `DECLINED` | Issuer or acquirer declined | Final for this attempt — don't retry the same card without a new attempt from the shopper |
| `FAILED` | The transaction didn't complete — the acquirer returned a non-decline error code, or the gateway couldn't place the request. **Not a guarantee that no funds moved** — `VOIDED` and `REVERSED` are the only statuses that positively assert that. | Confirm via `GET /v1/transactions/{id}/status` before retrying, then retry (a genuinely new attempt, not a replay of the same `request_id`) with a **new** `request_id` |
| `PENDING` | Outcome not yet known (async) | Wait for a webhook, or poll `GET /v1/transactions/{id}/status` |
| `AUTH_EXPIRED` | Authorization lapsed before capture | Create a new authorization |
| `REVERSAL_PENDING` / `REVERSED` | Automatic compensating reversal after an upstream timeout left the outcome genuinely unknown (never left `FAILED` in this case) | No merchant action; webhook confirms the final state |

A retry with the **same** `request_id` is always replayed — you get the stored refund back, whatever its status, including `DECLINED` or `FAILED`. This is checked before any of the refund gates (batch state, window, cap) run, so a changed refundable amount since your first call never turns a replay into a `422`. The match is: the same original transaction, the same `amount`, and a key that was previously used for a refund — `reason` isn't compared. Any other reuse of the key (a different original transaction, a different amount, or a key already used for something other than a refund) returns `409 urn:radiumone:transaction:idempotency-body-mismatch` instead of replaying.

<Warning>
  Because a `DECLINED` or `FAILED` refund replays too, retrying the **same** `request_id` after a decline just returns the same decline again — it never becomes a second attempt. To genuinely retry after a declined or failed refund, mint a **new** `request_id`.
</Warning>

A wrong `transaction_id` in the path always returns `404`, even if the `request_id` matches a stored refund — the 404 takes precedence over the replay.

## Errors

Full HTTP status and meaning for each of these is defined once on [Problem format and retries](/payments-api/errors/problem-format-and-retries) — this list is only the refund-specific nuance:

* [`tx:refund-on-open-batch`](/payments-api/errors/payment-operation-errors#tx-refund-on-open-batch) — [void](/payments-api/void) instead
* [`transaction:product-not-supported`](/payments-api/errors/payment-method-errors#transaction-product-not-supported) — refunding a loyalty leg directly isn't supported; see [Refunds and cancellations](/payments-api/payment-methods/uob-rewards/refunds-and-cancellations)
* [`tx:amount-exceeds-captured`](/payments-api/errors/payment-operation-errors#tx-amount-exceeds-captured) — for a **new** `request_id` only; a replay of an existing refund's key never hits this
* [`tx:currency-mismatch`](/payments-api/errors/payment-operation-errors#tx-currency-mismatch) — refunds must use the original transaction's currency
* [`routing:capability-not-supported`](/payments-api/errors/payment-method-errors#routing-capability-not-supported) — [request enablement](/resources/support#request-enablement)
* [`gateway:validation-error`](/payments-api/errors/payment-operation-errors#gateway-validation-error)

## No original transaction to reference?

If you need to credit a card without a prior RadiumOne transaction, see [Standalone refunds](/payments-api/standalone-refunds) — a high-risk, separately gated operation.

## Test your integration

See [Test your integration](/resources/test-your-integration#refund) for partial-refund and closed/open-batch scenarios.

## Go-live notes

* Request refund enablement for every acquirer channel you plan to refund through — it isn't on by default, and there's no self-service toggle. See [Request enablement](/resources/support#request-enablement).
* Refund only after you've confirmed the batch closed — check status or wait for a `settlement.*` webhook.
* Track how much you've already refunded against a transaction; the gateway enforces the cap, but your own reconciliation should too.
* Review the full [go-live checklist](/resources/go-live-checklist).

## Next steps

<Columns cols={2}>
  <Card title="Standalone refunds" icon="triangle-alert" href="/payments-api/standalone-refunds">
    Credit a card with no original transaction — high-risk, gated.
  </Card>

  <Card title="Settlement and reconciliation" icon="landmark" href="/payments-api/settlement-and-reconciliation">
    Confirm when a batch closes before you refund.
  </Card>
</Columns>
