> ## 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 redemption failures - Payments API

> What to do when a points redemption can't proceed or the loyalty host is unavailable.

A balance inquiry or a redemption purchase doesn't behave the way you expected — nothing is redeemable, the request itself is rejected, or the loyalty host doesn't respond in time.

<Info>
  **TL;DR** — `422` means fix the request (`loyalty` pairing or `kind`); a host timeout during a purchase degrades to a normal card-only `AUTHORIZED`, not an error — retry the redemption itself with a **new** `request_id` if you want another attempt.
</Info>

## When this happens

* The purchase sent a `loyalty` component, but the routed acquirer has no loyalty leg configured — `422 urn:radiumone:transaction:loyalty-leg-required`.
* The `loyalty.kind` you sent (`voucher` or `coupon`) is schema-accepted but not dispatchable — only `sale` (points redemption) is live — `422 urn:radiumone:loyalty:redemption-kind-not-supported`.
* A balance inquiry succeeds at the host level but nothing is redeemable for this card right now — `success: true` with empty `pools[]`/`vouchers[]`. This is not an error.
* The loyalty host doesn't respond in time during a purchase's redemption leg — the redemption is treated as a **warning, not a fatal failure**: the purchase falls through to a card-only authorization. See [Pay with points: outcomes](/payments-api/payment-methods/uob-rewards/pay-with-points#outcomes) for exactly what your server sees (a degraded, `AUTHORIZED`, no-`loyalty`-block result).

## What you see

| Signal | Value |
| - | - |
| HTTP status | `422` (loyalty-leg / redemption-kind) |
| Balance inquiry, nothing redeemable | `success: true`, empty `pools`/`vouchers` |
| Host timeout on a redemption purchase | Normal `201`, `status: "AUTHORIZED"`, **no** `loyalty` block in the response |

## What to do

<Steps>
  <Step title="Hide redemption when there's no balance">
    If a balance inquiry returns empty `pools[]`/`vouchers[]`, don't show a redemption option — let the shopper pay with the card alone ([API reference](/payments-api/reference/rewards/balance-inquiry)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Pre-sale UOB Rewards points/voucher balance. No transaction is
      # created. Branch on data.success, not response_code.
      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}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Pre-sale UOB Rewards points/voucher balance. No transaction is
      // created. 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)));

      async function checkRewardsBalance() {
        const res = await fetch(`${API_BASE}/v1/transactions/balance-inq`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${accessToken}`,
          },
          body: JSON.stringify(body),
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`balance-inq failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        // Branch on data.success, not response_code (a cataloged decline can still be success:true).
        return payload;
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Pre-sale UOB Rewards points/voucher balance. No transaction is created."""
      import json
      import os
      from pathlib import Path

      import requests

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


      def check_rewards_balance() -> dict:
          body = json.loads((Path(__file__).parent / "request.json").read_text())
          resp = requests.post(
              f"{API_BASE}/v1/transactions/balance-inq",
              json=body,
              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"balance-inq failed: {code} ({resp.status_code})")
          # Branch on data.success, not response_code (a cataloged decline can still be success:true).
          return payload


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

  <Step title="Only send kind: 'sale'">
    Don't send `loyalty.kind: "voucher"` or `"coupon"` — they're accepted by the schema but always rejected. Points and voucher redemption both go through `kind: "sale"` ([API reference](/payments-api/reference/payments/purchase)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Purchase with a UOB Rewards redemption. `amount` stays the GROSS
      # order total — the gateway redeems points first, then charges the card
      # residual. The `loyalty` block in the response is your outcome signal:
      # if absent, no points moved and the card was charged in full.
      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}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Purchase with a UOB Rewards redemption. `amount` stays the GROSS
      // order total — the gateway redeems points first, then charges the card
      // residual. The `loyalty` block in the response is your outcome signal:
      // if absent, no points moved and the card was charged in full.
      // Node 18+ ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_API_BASE (optional override).
      //
      // Shared result pattern: any 2xx is a response you branch on `data.status`.
      // On a network timeout, a 5xx, or `status:"PENDING"`, retry with the SAME
      // request_id — never mint a new one for the same attempt.
      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 createPurchaseWithRewards(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/purchase`, {
              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) {
            throw new Error(`request failed: ${payload.type ?? payload.code} (${res.status})`);
          }

          if (payload.data.status === "PENDING") {
            if (attempt === maxAttempts) return payload;
            await new Promise((r) => setTimeout(r, backoffMs(attempt)));
            continue;
          }

          return payload; // branch on data.status
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Purchase with a UOB Rewards redemption. `amount` stays the GROSS
      order total — the gateway redeems points first, then charges the card
      residual. The `loyalty` block in the response is your outcome signal:
      if absent, no points moved and the card was charged in full.

      Shared result pattern: any 2xx is a response you branch on 'status'. On a
      network timeout, a 5xx, or status 'PENDING', retry with the SAME
      request_id -- never mint a new one for the same attempt.
      """
      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_purchase_with_rewards(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/purchase", 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:
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"request failed: {code} ({resp.status_code})")

              if payload["data"]["status"] == "PENDING":
                  if attempt == max_attempts:
                      return payload
                  time.sleep(backoff_seconds(attempt))
                  continue

              return payload  # branch on data.status

          raise RuntimeError("unreachable")


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

  <Step title="Confirm loyalty-leg pairing before relying on this in production">
    A `422 loyalty-leg-required` means your acquirer/outlet isn't paired with a loyalty leg — see the [enablement checklist](/payments-api/payment-methods/uob-rewards/overview#enablement-checklist) and [contact support](/resources/support#request-enablement).
  </Step>

  <Step title="Monitor for degraded outcomes on a host timeout">
    If the response has no `loyalty` block, treat it exactly like a normal card purchase — the card is `AUTHORIZED` for the full gross amount, and you still need to [capture](/payments-api/capture) or [void](/payments-api/void) it. Add monitoring so an uncaptured degraded authorization doesn't go unnoticed. If you need to retry the redemption itself, use a **new** `request_id` per attempt.
  </Step>
</Steps>

## Test it

See [Test your integration](/resources/test-your-integration#uob-rewards) for partial, full, degraded, and timeout scenarios.

## Related

<Columns cols={2}>
  <Card title="Check a rewards balance" icon="wallet" href="/payments-api/payment-methods/uob-rewards/check-balance">
    Look up what's redeemable before the shopper pays.
  </Card>

  <Card title="Pay with points" icon="gift" href="/payments-api/payment-methods/uob-rewards/pay-with-points">
    The redemption purchase, and every outcome it can return.
  </Card>

  <Card title="UOB Rewards overview" icon="info" href="/payments-api/payment-methods/uob-rewards/overview">
    Eligibility and the enablement checklist.
  </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>
