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

# Webhooks - Payments API

> Set up an endpoint to receive asynchronous payment, refund, and settlement events, and build a handler that verifies, dedupes, and processes them safely.

Webhooks are how RadiumOne tells your server about things that happen outside a
direct API response — a payment that settles, a delayed decline, or a
settlement batch closing. They're also your recovery path when a request to
RadiumOne times out: the eventual outcome still arrives as an event even if
your original call never got a synchronous answer.

## How it works

1. Your server creates a payment (purchase or authorize) and gets a synchronous response.
2. RadiumOne emits an event (for example `payment.captured`) to your registered endpoint, signed with your webhook secret.
3. Your endpoint verifies the signature and responds `2xx` quickly.
4. If delivery fails, RadiumOne retries on a backoff schedule for up to about 30 hours before giving up on that event.
5. If you never see the event you expected (a missed delivery, or your endpoint was down), call an authenticated `GET` to resolve the current status instead of guessing.

## Before you begin

<Info>
  Your endpoint must be reachable over **HTTPS** from the public internet, respond within RadiumOne's delivery timeout, and return a `2xx` status only once you've durably accepted the event (see [Build your handler](#build-your-handler) below).
</Info>

## Register your endpoint

The exact self-service flow for registering a webhook endpoint and getting its `whsec_…` signing secret is still being finalized. Until then, [contact support](/resources/support) with the URL you want events delivered to. See [Sandbox and API keys](/get-started/sandbox-and-api-keys#key-types) for where the webhook secret fits among your other credentials.

## Build your handler

<Steps>
  <Step title="Read the raw request body">
    Capture the exact bytes RadiumOne sent — **before** any JSON parsing. Signature
    verification runs over the raw body; a re-serialized copy (even one that looks
    identical) produces a different signature and fails verification. Most frameworks
    parse the body automatically unless you opt out for this route.
  </Step>

  <Step title="Verify the signature">
    Check the `X-RadiumOne-Signature` header against the raw body before you trust
    anything in the payload. See [Verify webhook signatures](/payments-api/webhooks/verify-signatures)
    for the algorithm and generated verifier code.
  </Step>

  <Step title="Dedupe on the event ID">
    Deliveries are **at-least-once** — the same event can arrive more than once. Store
    each event's top-level `id` (also echoed in the `X-RadiumOne-Event-Id` header) and
    skip processing if you've already seen it. See [Prevent duplicate
    payments](/get-started/api-basics/prevent-duplicate-payments) for how this fits into
    your wider duplicate-prevention strategy.
  </Step>

  <Step title="Enqueue, don't process inline">
    Hand the event off to a queue or background job before doing slow work (database
    writes, fulfilment, notifications). Responding fast matters more than finishing fast.
  </Step>

  <Step title="Respond 2xx immediately">
    Return a `2xx` status as soon as you've durably accepted the event — not after
    fulfilment completes. A slow or non-2xx response is treated as a delivery failure and
    triggers a retry, which can duplicate work if your processing isn't idempotent.
  </Step>

  <Step title="Process the event">
    Only now branch on `type` and `data` to fulfil the order, update your records, or
    trigger reconciliation. See [Webhook event types](/payments-api/webhooks/event-types) for every
    event and its payload shape.
  </Step>
</Steps>

A minimal handler putting all of this together:

<CodeGroup>
  ```javascript Node.js theme={null}
  #!/usr/bin/env node
  // Minimal webhook handler using Node's built-in http server (no framework).
  // Verify the signature on the RAW body, respond 2xx quickly (before any slow
  // work), and dedupe on data.id -- deliveries are at-least-once and unordered.
  import { createServer } from "node:http";
  import { createHmac, timingSafeEqual } from "node:crypto";

  const WEBHOOK_SECRET = process.env.RADIUMONE_WEBHOOK_SECRET ?? "";
  const seenEventIds = new Set(); // replace with a persistent store in production

  const SECRET_PATTERN = /^whsec_[0-9a-fA-F]{64}$/;
  const SIGNATURE_HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
  const TIMESTAMP_PATTERN = /^\d+$/;

  // Fails closed on every malformed input (missing header, bad secret shape,
  // non-integer timestamp) -- never throws. See verify-webhook-signature for
  // the fully-documented, standalone version of this function.
  function verifySignature(secret, header, rawBody, toleranceSeconds = 300) {
    if (typeof secret !== "string" || !SECRET_PATTERN.test(secret)) return false;
    if (typeof header !== "string" || header.length === 0) return false;

    const fields = {};
    for (const part of header.split(",")) {
      const idx = part.indexOf("=");
      if (idx < 0) return false;
      fields[part.slice(0, idx)] = part.slice(idx + 1);
    }
    const { v, t, s } = fields;
    if (!v || !t || !s) return false;
    if (v !== "1") return false;
    if (!TIMESTAMP_PATTERN.test(t)) return false;
    if (!SIGNATURE_HEX_PATTERN.test(s)) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false;

    const key = Buffer.from(secret.slice("whsec_".length), "hex");
    const signingInput = Buffer.concat([Buffer.from(`${v}.${t}.`), rawBody]);
    const expectedHex = createHmac("sha256", key).update(signingInput).digest("hex");

    const expected = Buffer.from(expectedHex, "hex");
    const actual = Buffer.from(s, "hex");
    return expected.length === actual.length && timingSafeEqual(expected, actual);
  }

  const server = createServer((req, res) => {
    if (req.method !== "POST" || req.url !== "/webhooks/radiumone") {
      res.writeHead(404).end();
      return;
    }

    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const rawBody = Buffer.concat(chunks); // verify against RAW bytes, not parsed JSON
      const header = req.headers["x-radiumone-signature"] ?? "";

      if (!verifySignature(WEBHOOK_SECRET, header, rawBody)) {
        res.writeHead(401).end();
        return;
      }

      const event = JSON.parse(rawBody.toString("utf8"));
      if (seenEventIds.has(event.id)) {
        res.writeHead(200).end(); // duplicate delivery: acknowledge, don't reprocess
        return;
      }
      seenEventIds.add(event.id);

      // Respond 2xx immediately; do slow work (DB writes, fulfilment) after
      // responding, e.g. via a queue. Never rely on the payload alone to
      // fulfil -- confirm amount and status match your order first.
      res.writeHead(200).end();
      console.log("received", event.type, event.id);
    });
  });

  server.listen(process.env.PORT ?? 3001);
  ```

  ```python Python theme={null}
  #!/usr/bin/env python3
  """Minimal webhook handler using Python's built-in http.server (no framework).

  Verify the signature on the RAW body, respond 2xx quickly (before any slow
  work), and dedupe on ``data.id`` -- deliveries are at-least-once and
  unordered.
  """
  import hmac
  import hashlib
  import json
  import os
  import re
  import time
  from http.server import BaseHTTPRequestHandler, HTTPServer

  WEBHOOK_SECRET = os.environ.get("RADIUMONE_WEBHOOK_SECRET", "")
  SEEN_EVENT_IDS: set[str] = set()  # replace with a persistent store in production

  _SECRET_PATTERN = re.compile(r"^whsec_[0-9a-fA-F]{64}$")
  _SIGNATURE_HEX_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$")
  _TIMESTAMP_PATTERN = re.compile(r"^\d+$")


  # Fails closed on every malformed input (missing header, bad secret shape,
  # non-integer timestamp) -- never raises. See verify-webhook-signature for
  # the fully-documented, standalone version of this function.
  def verify_signature(secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
      if not _SECRET_PATTERN.match(secret):
          return False
      if not header:
          return False

      fields = {}
      for part in header.split(","):
          if "=" not in part:
              return False
          k, _, v = part.partition("=")
          fields[k] = v

      v, t, s = fields.get("v"), fields.get("t"), fields.get("s")
      if not v or not t or not s:
          return False
      if v != "1":
          return False
      if not _TIMESTAMP_PATTERN.match(t):
          return False
      if not _SIGNATURE_HEX_PATTERN.match(s):
          return False
      if abs(int(time.time()) - int(t)) > tolerance_seconds:
          return False

      key = bytes.fromhex(secret[len("whsec_"):])
      signing_input = f"{v}.{t}.".encode("utf-8") + raw_body
      expected_hex = hmac.new(key, signing_input, hashlib.sha256).hexdigest()
      return hmac.compare_digest(bytes.fromhex(expected_hex), bytes.fromhex(s))


  class WebhookHandler(BaseHTTPRequestHandler):
      def do_POST(self):
          if self.path != "/webhooks/radiumone":
              self.send_response(404)
              self.end_headers()
              return

          length = int(self.headers.get("Content-Length", 0))
          raw_body = self.rfile.read(length)  # verify against RAW bytes, not parsed JSON
          header = self.headers.get("X-RadiumOne-Signature", "")

          if not verify_signature(WEBHOOK_SECRET, header, raw_body):
              self.send_response(401)
              self.end_headers()
              return

          event = json.loads(raw_body)
          if event["id"] in SEEN_EVENT_IDS:
              self.send_response(200)  # duplicate delivery: acknowledge, don't reprocess
              self.end_headers()
              return
          SEEN_EVENT_IDS.add(event["id"])

          # Respond 2xx immediately; do slow work (DB writes, fulfilment) after
          # responding, e.g. via a queue. Never rely on the payload alone to
          # fulfil -- confirm amount and status match your order first.
          self.send_response(200)
          self.end_headers()
          print("received", event["type"], event["id"])


  if __name__ == "__main__":
      HTTPServer(("", int(os.environ.get("PORT", 3001))), WebhookHandler).serve_forever()
  ```
</CodeGroup>

## Recovery after timeouts

If a purchase, authorize, capture, void, or refund call times out or returns a `5xx`
or `PENDING` result, don't guess at the outcome. Either wait for the matching webhook,
or call `GET /v1/transactions/{id}/status` directly ([API reference](/payments-api/reference/transactions/transaction-status-inquiry)) — see
[Handle timeouts and unknown outcomes](/payments-api/handle-failures/timeouts-and-unknown-outcomes) for the full recovery walkthrough.

<Warning>
  Treat any client-side redirect or callback as a hint only. Always confirm the final payment status from your server, using an authenticated `GET` request or a webhook — never from a query parameter or browser postMessage alone.
</Warning>

## Test your integration

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

## Go-live notes

* Register your production endpoint and rotate to a production `whsec_…` secret before accepting real payments — see [Verify webhook signatures](/payments-api/webhooks/verify-signatures#rotating-your-secret).
* Make sure your endpoint responds fast and only over HTTPS; a slow endpoint looks identical to a broken one and can eventually be suspended — see [Retries, ordering, and duplicates](/payments-api/webhooks/retries-and-ordering#endpoint-suspension).
* Review the full [go-live checklist](/resources/go-live-checklist).

## Next steps

<Columns cols={2}>
  <Card title="Verify webhook signatures" icon="key-round" href="/payments-api/webhooks/verify-signatures">
    Algorithm, key encoding, and generated verifier code.
  </Card>

  <Card title="Webhook event types" icon="webhook" href="/payments-api/webhooks/event-types">
    Every event, its payload shape, and the fields RadiumOne publishes.
  </Card>

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

  <Card title="Settlement and reconciliation" icon="landmark" href="/payments-api/settlement-and-reconciliation">
    Track settlement batches with `settlement.*` events.
  </Card>

  <Card title="Recover from missed, duplicate or out-of-order webhooks" icon="refresh-cw" href="/payments-api/handle-failures/missed-duplicate-or-out-of-order-webhooks">
    What to do when delivery doesn't go as expected.
  </Card>
</Columns>
