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

# Webhook delivery issues - Payments API

> How to reconcile when a webhook is missed, delivered twice, or arrives out of order.

Webhook delivery is at-least-once and unordered by design. You saw a duplicate `id`, events for the same transaction arrived out of sequence, or you never saw an event you expected at all.

<Info>
  **TL;DR** — Dedupe on the event `id`, sort by `created_at` for true order, and reconcile against your own stored transaction `id`s with status inquiry rather than waiting indefinitely.
</Info>

## When this happens

* **Duplicate**: the same event `id` arrives more than once — after a retry, or after a network blip hid a successful `2xx` from RadiumOne.
* **Out of order**: a retry of an earlier event arrives after a later one for the same transaction.
* **Missed entirely**: your endpoint was down, slow, or — if failures were sustained enough — suspended (fatal failure kinds, ≥5 deliveries over ≥24 hours; recovery is admin/support-only, not automatic).

## What you see

| Signal | Value |
| - | - |
| Repeated `id` | Same top-level `id` (also in `X-RadiumOne-Event-Id`) seen more than once |
| Out-of-order | `created_at` on a newly-arrived event is earlier than one you already processed |
| Missed | No event arrives for an operation you know happened |

## What to do

<Steps>
  <Step title="Dedupe on the event ID">
    Track `id` values you've already processed and skip a repeat — see [Webhooks overview: build your handler](/payments-api/webhooks/overview#build-your-handler) for a minimal handler you can adapt. In production, back this with a persistent store that enforces a **unique constraint on the event `id`** (a database column, not just an in-memory set) so a duplicate insert fails safely even across restarts or multiple server instances.
  </Step>

  <Step title="Order by created_at, not by arrival order">
    If you need the true sequence for one transaction, sort by each event's `created_at` rather than trusting delivery order — see [Retries, ordering, and duplicates: ordering](/payments-api/webhooks/retries-and-ordering#ordering).
  </Step>

  <Step title="Run a reconciliation job against your own stored transaction IDs">
    Don't wait indefinitely for a webhook that may never arrive. RadiumOne has no list-by-`order_reference` or get-by-ID endpoint — reconciliation depends on the transaction `id`s you already stored from create responses and prior webhooks. For any of those you haven't heard a terminal outcome for, poll status inquiry ([API reference](/payments-api/reference/transactions/transaction-status-inquiry)):

    <CodeGroup>
      ```bash cURL theme={null}
      #!/usr/bin/env bash
      # Live acquirer status inquiry for a transaction (no new row written). Use
      # this to recover after a timeout instead of guessing the outcome.
      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 transaction to inquire}"

      curl -sS "$API_BASE/v1/transactions/$RADIUMONE_TRANSACTION_ID/status" \
        -H "Authorization: Bearer $RADIUMONE_ACCESS_TOKEN"
      ```

      ```javascript Node.js theme={null}
      #!/usr/bin/env node
      // Live acquirer status inquiry for a transaction (no new row written). Use
      // this to recover after a timeout instead of guessing the outcome. Node 18+
      // ESM fetch. Env: RADIUMONE_ACCESS_TOKEN, RADIUMONE_TRANSACTION_ID, RADIUMONE_API_BASE.
      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;

      async function inquireTransactionStatus() {
        const res = await fetch(`${API_BASE}/v1/transactions/${transactionId}/status`, {
          headers: { Authorization: `Bearer ${accessToken}` },
        });
        const payload = await res.json();
        if (!res.ok) {
          throw new Error(`status inquiry failed: ${payload.type ?? payload.code} (${res.status})`);
        }
        return payload; // branch on data.status
      }

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

      ```python Python theme={null}
      #!/usr/bin/env python3
      """Live acquirer status inquiry for a transaction (no new row written). Use
      this to recover after a timeout instead of guessing the outcome.
      """
      import json
      import os

      import requests

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


      def inquire_transaction_status() -> dict:
          transaction_id = os.environ["RADIUMONE_TRANSACTION_ID"]
          resp = requests.get(
              f"{API_BASE}/v1/transactions/{transaction_id}/status",
              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"status inquiry failed: {code} ({resp.status_code})")
          return payload  # branch on data.status


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

    For batch-level reconciliation, see [Retrieve a settlement batch](/payments-api/reference/settlement/retrieve-settlement-batch).
  </Step>

  <Step title="Re-register a suspended endpoint through support">
    If your endpoint was suspended, fix the underlying issue first (a bad hostname, an expired TLS cert, a route that returns `410`), then [contact support](/resources/support) to reactivate it — see [Retries, ordering, and duplicates: endpoint suspension](/payments-api/webhooks/retries-and-ordering#endpoint-suspension).
  </Step>
</Steps>

## Prevent it

* Make your handler idempotent on event `id` before you go live.
* Monitor for an unexpected gap in webhook traffic — a suspended endpoint receives nothing and gives you no other signal.

## Test it

See [Test your integration](/resources/test-your-integration#webhooks) for simulated retry, duplicate-delivery, and out-of-order scenarios.

## Related

<Columns cols={2}>
  <Card title="Webhooks overview" icon="webhook" href="/payments-api/webhooks/overview">
    Endpoint setup and the handler steps.
  </Card>

  <Card title="Retries, ordering, and duplicates" icon="refresh-cw" href="/payments-api/webhooks/retries-and-ordering">
    Delivery guarantees, backoff schedule, and suspension mechanics.
  </Card>
</Columns>
