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

# Capture failures - Payments API

> Why a capture can fail after its window expires or the amount doesn't match, and what to do.

A capture is rejected, or succeeds at the HTTP level but the acquirer declines it.

<Info>
  **TL;DR** — `422` means fix the window or amount and don't retry as-is; a `200` with the authorization row **unchanged** (still `AUTHORIZED`) is an ordinary acquirer decline, not an error.
</Info>

## When this happens

* You captured after the capture window closed (7 days by default) — `422 urn:radiumone:tx:capture-window-expired`, and the authorization has already lapsed to `AUTH_EXPIRED`.
* Your capture `amount` doesn't exactly equal the authorized amount — `422 urn:radiumone:tx:capture-amount-mismatch`. Partial capture isn't supported in this API version.
* The authorization expired mid-flight, between your check and your capture call (an automatic expiry job raced your request) — `409 urn:radiumone:tx:auth-expired-during-capture`.
* The acquirer itself declines the capture — this is a normal `200` response, but the response is the **authorization row unchanged** (still `status: "AUTHORIZED"`), not a new row and not `"FAILED"`. The HTTP body alone can't tell you the capture failed — watch for `authorization.capture_declined` / `authorization.capture_failed`.

## What you see

| Signal | Value |
| - | - |
| HTTP status | `422` (window/amount) or `409` (expired mid-flight) or `200` with the authorization unchanged (acquirer decline) |
| Error type | `tx:capture-window-expired` (422) / `tx:capture-amount-mismatch` (422) / `tx:auth-expired-during-capture` (409) |
| Webhook | `authorization.expired`, or `authorization.capture_declined` / `authorization.capture_failed` |

## What to do

<Steps>
  <Step title="Capture the exact authorized amount, within the window">
    Send `amount` equal to the authorization's amount, before the capture window closes ([API reference](/payments-api/reference/payments/capture-an-authorised-transaction)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Capture a prior authorization (must equal the authorized amount in v1). Any
      # 2xx is a response — branch on data.status. On a timeout/5xx, retry with the
      # SAME operation_id; never mint a new one for the same capture attempt.
      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 id of the AUTHORIZED transaction}"

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

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Capture a prior authorization (must equal the authorized amount in v1).
      // Node 18+ ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_TRANSACTION_ID,
      // RADIUMONE_API_BASE (optional override).
      //
      // Shared result pattern: any 2xx is a response you branch on `data.status`.
      // On a network timeout or 5xx, retry with the SAME operation_id — never mint
      // a new one for the same capture 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 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 captureAuthorization(maxAttempts = 3) {
        for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
          let res;
          try {
            res = await fetch(`${API_BASE}/v1/transactions/${transactionId}/capture`, {
              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) {
            // 422 tx:capture-window-expired if the capture window has passed.
            throw new Error(`capture failed: ${payload.type ?? payload.code} (${res.status})`);
          }
          return payload; // branch on data.status
        }
        throw new Error("unreachable");
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Capture a prior authorization (must equal the authorized amount in v1).

      Shared result pattern: any 2xx is a response you branch on ``status``. On a
      network timeout or 5xx, retry with the SAME operation_id — never mint a new
      one for the same capture 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 capture_authorization(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}/capture", 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:
                  # 422 tx:capture-window-expired if the capture window has passed.
                  code = payload.get("type") or payload.get("code")
                  raise RuntimeError(f"capture failed: {code} ({resp.status_code})")
              return payload  # branch on data.status

          raise RuntimeError("unreachable")


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

  <Step title="If the window already expired, start over">
    A `422 capture-window-expired` (or an `AUTH_EXPIRED` transaction) can't be captured — create a new authorization instead. See [Capture an authorization: the capture window](/payments-api/capture#the-capture-window).
  </Step>

  <Step title="If the amount mismatched, fix the amount — don't retry as-is">
    `422 capture-amount-mismatch` means you sent the wrong figure. If you need to charge less than the authorization, capture the full amount and refund the difference once the batch closes, or void and re-authorize for the right amount.
  </Step>

  <Step title="Treat an acquirer-declined capture like any other decline">
    A `200` with the authorization's `status` unchanged (still `AUTHORIZED`, not a new `CAPTURED` or `FAILED` row) isn't a system error — it means the capture didn't go through. Confirm via the `authorization.capture_declined` / `authorization.capture_failed` webhook and follow [Handle declined payments](/payments-api/handle-failures/declined-payments) for how to message it; retry with a new `operation_id` only after you've confirmed the decline.
  </Step>
</Steps>

## Test it

See [Test your integration](/resources/test-your-integration#capture) for capture-window and decline scenarios.

## Related

<Columns cols={2}>
  <Card title="Capture an authorization" icon="check-check" href="/payments-api/capture">
    The full-capture rule and the capture window.
  </Card>

  <Card title="Payment lifecycle" icon="repeat" href="/payments-api/payment-lifecycle#authorization-expiry">
    How `AUTH_EXPIRED` fits into the lifecycle.
  </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>
