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

# Standalone refunds - Payments API

> Credit a card with no original RadiumOne transaction bounding the amount. High-risk: requires enablement and least-privilege keys.

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

<Warning>
  This feature is in **Beta**. Behavior may change before general availability, and production access depends on your RadiumOne rollout. [Contact support](/resources/support) to confirm availability for your account.
</Warning>

A standalone (or "open") refund credits a card with **no original RadiumOne transaction** to bound the amount, the card, or the timing — you supply the card token and amount directly. It's the escape hatch for refunding an order that was never paid for through RadiumOne (a legacy order, a goodwill credit, a chargeback pre-emption), not the normal refund path.

<Warning>
  **This is a high-risk operation.** Because there's no original transaction, RadiumOne can't cap the amount, enforce a time window, or confirm the refund matches the card that was originally charged. Every secret key holds the `transaction-refund-unreferenced` scope by default — the scope itself isn't what protects you, the acquirer channel/device enablement gate is. Once enabled, anyone holding that key can credit **any** card token for **any** amount, at any time. Prefer [Refund a payment](/payments-api/refund) whenever an original transaction exists.
</Warning>

<RequiresEnablement feature="Standalone 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>

<Warning>
  This is a high-risk operation. Follow these practices:

  * **Least privilege**: request a secret key scoped to only the operations it needs, not a key with every scope.
  * **Key storage**: store secret keys in a server-side secrets manager, never in client code, source control, or logs.
  * **Emergency revocation**: if a key is compromised, rotate or revoke it immediately — see [Emergency key revocation](/resources/support#emergency-key-revocation).
  * **Monitoring**: monitor refunds and settlements for unexpected activity and alert on anomalies.
</Warning>

## Request fields

Same shape as a purchase: `request_id`, `amount`, `card`, `channel`, plus optional `order_reference` (up to 128 characters) and `reason` (up to 200 characters) for your own records.

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

## Steps

<Steps>
  <Step title="Issue the refund">
    Use a secret key scoped to `transaction-refund-unreferenced`. The outlet must also be enabled for standalone refunds at the acquirer/terminal level. [API reference](/payments-api/reference/refunds/standalone-refund).

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # High-risk. Standalone ("open") refund — credits a card with NO original
      # RadiumOne transaction bounding the amount. Requires the
      # transaction-refund-unreferenced scope PLUS acquirer/terminal enablement
      # (see resources/support#request-enablement). Least-privilege keys only.
      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 with the transaction-refund-unreferenced scope}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // High-risk. Standalone ("open") refund — credits a card with NO original
      // RadiumOne transaction bounding the amount. Requires the
      // transaction-refund-unreferenced scope PLUS acquirer/terminal enablement
      // (see resources/support#request-enablement). Least-privilege keys only.
      // Node 18+ ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, 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 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 createStandaloneRefund(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/refund`, {
              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) {
            // 403 auth:insufficient-scope, or a routing 409/422 if not yet enabled.
            throw new Error(`open refund failed: ${payload.type ?? payload.code} (${res.status})`);
          }
          return payload;
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """High-risk. Standalone ("open") refund — credits a card with NO original
      RadiumOne transaction bounding the amount. Requires the
      transaction-refund-unreferenced scope PLUS acquirer/terminal enablement (see
      resources/support#request-enablement). Least-privilege keys only.
      """
      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_standalone_refund(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/refund", 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:
                  # 403 auth:insufficient-scope, or a routing 409/422 if not yet enabled.
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"open refund failed: {code} ({resp.status_code})")
              return payload

          raise RuntimeError("unreachable")


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

    If the operation isn't enabled for your outlet, see [Fix operations unavailable for your outlet](/payments-api/handle-failures/operation-unavailable-for-outlet).
  </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 |

| Key | Used by | On replay |
| - | - | - |
| `request_id` | Purchase, authorize, standalone and referenced refunds | Same body, same operation type → the original transaction, whatever its status — including `PENDING`, `DECLINED`, or `FAILED`. Changed body, or the same key reused for a different operation type → [`transaction:idempotency-body-mismatch`](/payments-api/errors/payment-operation-errors#transaction-idempotency-body-mismatch). Purchase/authorize/standalone-refund compare `amount`, `currency`, `payment_method_type`, `channel`, the card's `pan_prefix` (first 8 digits — not the full token), `metadata`, and `order_reference`; a **referenced refund** compares only the original transaction and `amount` (`reason` isn't compared) and its replay check runs before the refund gates, so it always replays, even a `DECLINED`/`FAILED` one — mint a **new** `request_id` to retry after a decline. None of these compare `three_ds` or `loyalty`, so changing either on a retry replays the original silently instead of failing. |
| `operation_id` | Capture, void | Same operation type on the same transaction → the original result (body is never compared, so a changed amount is silently ignored). A different operation type reusing the key → [`tx:duplicate-operation`](/payments-api/errors/payment-operation-errors#tx-duplicate-operation). |

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

<Tip>
  Keys are 8–64 characters, `[a-zA-Z0-9-]` only, unique per merchant account. Generate one key per order **attempt** and persist it to your database before you send the first request — never mint a new key just to retry the same attempt. See [Prevent duplicate payments](/get-started/api-basics/prevent-duplicate-payments).
</Tip>

`PENDING` means the outcome isn't known yet — most often after a processor timeout. Don't assume success or failure. Recover it one of two ways:

1. **Wait for a webhook** (`payment.*`, `authorization.*`, `refund.*` — see [Webhook event types](/payments-api/webhooks/event-types)).
2. **Call `GET /v1/transactions/{id}/status`** for a live inquiry against the acquirer.

If you don't have the transaction `id` yet — a client-side timeout before the first response arrived — replay the same request with the same `request_id` and body. The replay returns the stored transaction and its `id`, whatever status it's reached. Never re-submit with a **new** idempotency key just because the first attempt is slow — that risks a second charge for the same order.

Unlike a referenced refund, a standalone refund is a **create**-type operation: it returns `201` on success (and on a replay with the same `request_id`), matching purchase and authorize.

## 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 standalone-refund-specific nuance:

* [`auth:insufficient-scope`](/payments-api/errors/payment-operation-errors#auth-insufficient-scope) — your key's token doesn't have the `transaction-refund-unreferenced` scope
* [`routing:operation-disabled`](/payments-api/errors/payment-method-errors#routing-operation-disabled) — standalone refunds aren't enabled for any terminal that can route this request
* [`device:operation-not-supported`](/payments-api/errors/payment-method-errors#device-operation-not-supported) — the routed terminal has this operation toggled off
* [`gateway:validation-error`](/payments-api/errors/payment-operation-errors#gateway-validation-error)
* [`transaction:idempotency-body-mismatch`](/payments-api/errors/payment-operation-errors#transaction-idempotency-body-mismatch) — same body-hash comparison as purchase and authorize: `request_id`, `amount`, `currency`, `payment_method_type`, `channel_type`, the card's `pan_prefix` (first 8 digits, not the full token), `metadata`, and `order_reference` are hashed and compared. `reason` and the full card token aren't hashed, so changing either on a retry still replays the original silently.

## Voiding a standalone refund

A standalone refund is its own transaction and follows the same [void-or-refund rule](/payments-api/void#void-or-refund-never-both) as any other capture: you can void it while its settlement batch is still open, and you can't once it closes.

## Monitoring

Because a leaked or over-scoped key can issue standalone refunds silently, treat refund volume and amount as a security signal, not just a finance one:

* Alert on any standalone refund above your normal order size, or on a burst of them in a short window.
* Reconcile standalone refunds against a real business reason (the goodwill credit, the chargeback case) — an unexplained one is an incident, not a bookkeeping question.
* Review who holds keys scoped to `transaction-refund-unreferenced` on a regular cadence, and remove the scope from any key that doesn't need it.

## Test your integration

See [Test your integration](/resources/test-your-integration#standalone-refunds) once enablement is confirmed for your sandbox outlet.

## Go-live notes

* Request a key scoped to only `transaction-refund-unreferenced` (not a general-purpose key) for whatever service issues these refunds.
* Log every standalone refund with the business reason before you call the API, not after.
* Review the full [go-live checklist](/resources/go-live-checklist#high-risk-operations).

## Next steps

<Columns cols={2}>
  <Card title="Refund a payment" icon="undo-2" href="/payments-api/refund">
    Use the referenced refund whenever an original transaction exists.
  </Card>

  <Card title="Security and PCI scope" icon="shield-check" href="/resources/security-and-pci">
    Key storage, rotation, and emergency revocation guidance.
  </Card>
</Columns>
