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

# Rewards refund limits - Payments API

> The restrictions on voiding or refunding a sale that redeemed UOB Rewards points, and what to do when a request is rejected.

You tried to void or refund one leg of a redeemed sale on its own, and it was rejected.

<Info>
  **TL;DR** — A merchant can't void or refund a single leg of a grouped sale through the API. Void the whole group via support; refund only the payment leg yourself.
</Info>

## When this happens

* You tried to void just the payment leg (or just the loyalty leg) of a grouped sale — `409 urn:radiumone:transaction:single-leg-void-forbidden`. A merchant can't void one leg of a group; whole-group void is support/admin-only.
* You tried to refund the loyalty leg directly — `422 urn:radiumone:transaction:product-not-supported`. Points aren't returned through the API.

## What you see

| Signal | Value |
| - | - |
| HTTP status | `409` (single-leg void) or `422` (refund the loyalty leg) |
| Error type | `transaction:single-leg-void-forbidden` / `transaction:product-not-supported` |

## What to do

<Steps>
  <Step title="Void the whole group, not one leg">
    A merchant can't self-void a single leg of a grouped sale. To cancel a redeemed sale before its batch closes, [contact support](/resources/support) with the `group_id` from your records — see [Refunds and cancellations: cancelling a redeemed sale](/payments-api/payment-methods/uob-rewards/refunds-and-cancellations#cancelling-a-redeemed-sale).
  </Step>

  <Step title="Refund the payment leg, not the loyalty leg">
    Once the batch has closed, refund the card residual through the payment leg's own transaction ([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>

    The loyalty leg itself can't be refunded through the API — support handles points returns.
  </Step>

  <Step title="Treat a full-redemption return as a support case">
    If the sale had no payment leg at all (a full-redemption, loyalty-only sale), there's nothing to refund through the API — [contact support](/resources/support).
  </Step>
</Steps>

## Related

<Columns cols={2}>
  <Card title="Refunds and cancellations" icon="undo-2" href="/payments-api/payment-methods/uob-rewards/refunds-and-cancellations">
    The full can/can't table for grouped sales.
  </Card>

  <Card title="Pay with points" icon="gift" href="/payments-api/payment-methods/uob-rewards/pay-with-points">
    Where `group_id` and the loyalty block come from.
  </Card>

  <Card title="Payment method errors" icon="triangle-alert" href="/payments-api/errors/payment-method-errors#loyalty-uob-rewards">
    The full URN catalog for loyalty errors.
  </Card>
</Columns>
