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

# Verify webhook signatures - Payments API

> Verify the HMAC signature on every webhook delivery before you trust its payload, using the same algorithm and test vectors RadiumOne verifies against.

Every webhook delivery carries an `X-RadiumOne-Signature` header. Verify it against
the raw request body before you trust anything in the payload — an unverified
webhook is just an HTTP POST from whoever can reach your endpoint.

## Header anatomy

```
X-RadiumOne-Signature: v=1,t=1788147026,k=9b4ad664,s=e4cd0b87e9dd8eeb3390b2d4e7d35b07480bfab5a1d687bfe055fd7337a7b119
```

| Field | Meaning |
| - | - |
| `v` | Signature scheme version. Bumped only if the algorithm or signing input changes — never for a key rotation. **Reject any `v` you don't recognize** rather than falling back to a default. |
| `t` | Unix timestamp the signature was generated at. Used for the freshness check below. |
| `k` | First 8 hex characters of `sha256(signing key)` — a key id. **Advisory only.** Never fetch or derive a verification key from it; always verify with the secret you already hold. |
| `s` | The HMAC-SHA256 signature, hex-encoded. |

Two companion headers accompany every delivery: `X-RadiumOne-Event-Id` (same value
as the payload's `id`, useful for logging before you've parsed the body) and
`X-RadiumOne-Event-Type`.

## Algorithm

1. Parse `v`, `t`, and `s` from the header.
2. Build the signing string: `` `${v}.${t}.` `` (literal dot-joined `v` and `t`) concatenated with the **raw request body bytes** — not a re-serialized copy.
3. Compute `HMAC-SHA256(key, signing_string)` and hex-encode it.
4. Compare it to `s` using a **constant-time comparison** — never `===` or `hmac == string`, which can leak timing information about how many leading bytes matched.
5. Reject if `t` is outside your tolerance window (300 seconds / 5 minutes is the reference implementation's default) — this defends against replaying an old, otherwise-valid delivery.

<Warning>
  **Key encoding differs from the hosted-checkout redirect signature.** The webhook
  signing key is the **raw 32 bytes** you get by stripping the `whsec_` prefix and
  **hex-decoding** the remainder — never the display string itself. This is the
  opposite of the redirect signature, whose key is the **full `rsec_…` string**
  passed directly as the HMAC key (see
  [Verify the payment result](/hosted-checkout/verify-payment-result#ux-hint-the-redirect-signature)).
  Using the wrong encoding for either one makes every signature fail to verify.
</Warning>

## Verify the signature

<CodeGroup>
  ```javascript Node.js theme={null}
  #!/usr/bin/env node
  // Verify a RadiumOne webhook signature. Node 18+, no dependencies.
  //
  // Key: hex-decode the part AFTER the `whsec_` prefix -> 32 raw bytes (this
  // differs from the HPP redirect signature, which uses the full secret
  // STRING as the HMAC key -- see verify-redirect-signature).
  // Signing string: `${v}.${t}.` + the raw request body bytes (exactly as
  // received, before any JSON re-serialization).
  // Header shape: X-RadiumOne-Signature: v=1,t=<unix>,k=<8hex>,s=<64hex>.
  // `k` is advisory only -- never fetch/derive a key from it.
  //
  // Fails closed: a malformed secret, a malformed/missing header, a
  // non-integer timestamp, or any unrecognized `v` all return false rather
  // than throwing or silently signing with an empty/wrong-shaped key.
  import { createHmac, timingSafeEqual } from "node:crypto";

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

  /**
   * @param {{secret: string, header: string, rawBody: string|Buffer, toleranceSeconds?: number, now?: number}} args
   * @returns {boolean}
   */
  export function verifyWebhookSignature({ secret, header, rawBody, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000) }) {
    // Fail closed: the secret must be the whsec_<64 hex> shape (32 raw bytes)
    // -- never proceed with an empty, truncated, or wrong-type secret.
    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; // malformed field, no `=`
      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; // reject any scheme version we don't implement
    if (!TIMESTAMP_PATTERN.test(t)) return false; // non-integer timestamp, no exception
    if (!SIGNATURE_HEX_PATTERN.test(s)) return false;

    const timestamp = Number(t);
    if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) return false;

    const key = Buffer.from(secret.slice("whsec_".length), "hex");

    const bodyBuf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody ?? "", "utf8");
    const signingInput = Buffer.concat([Buffer.from(`${v}.${t}.`, "utf8"), bodyBuf]);
    const expected = Buffer.from(createHmac("sha256", key).update(signingInput).digest("hex"), "hex");
    const actual = Buffer.from(s, "hex");

    if (expected.length !== actual.length) return false;
    return timingSafeEqual(expected, actual);
  }

  // Example: an Express-style raw-body handler.
  // app.post("/webhooks/radiumone", express.raw({ type: "application/json" }), (req, res) => {
  //   const ok = verifyWebhookSignature({
  //     secret: process.env.RADIUMONE_WEBHOOK_SECRET,
  //     header: req.header("X-RadiumOne-Signature"),
  //     rawBody: req.body, // Buffer, NOT req.body after JSON parsing
  //   });
  //   if (!ok) return res.status(401).end();
  //   res.status(200).end();
  // });
  ```

  ```python Python theme={null}
  #!/usr/bin/env python3
  """Verify a RadiumOne webhook signature. Python 3.10+, standard library only.

  Key: hex-decode the part AFTER the ``whsec_`` prefix -> 32 raw bytes (this
  differs from the HPP redirect signature, which uses the full secret STRING
  as the HMAC key -- see verify-redirect-signature).
  Signing string: ``{v}.{t}.`` + the raw request body bytes (exactly as
  received, before any JSON re-serialization).
  Header shape: ``X-RadiumOne-Signature: v=1,t=<unix>,k=<8hex>,s=<64hex>``.
  ``k`` is advisory only -- never fetch/derive a key from it.

  Fails closed: a malformed secret, a malformed/missing header, a non-integer
  timestamp, or any unrecognized ``v`` all return False rather than raising or
  silently signing with an empty/wrong-shaped key.
  """
  import hmac
  import hashlib
  import re
  import time

  # fullmatch (not match) + re.ASCII: plain `match()` with a trailing `$` lets
  # Python accept a string with one trailing "\n" (a `$` special case, same
  # class of bug as PCRE without the `D` modifier); `\d` without re.ASCII also
  # matches non-ASCII Unicode digits, which `int()` happily parses too.
  _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+$", re.ASCII)


  def verify_webhook_signature(secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300, now: int | None = None) -> bool:
      now = int(time.time()) if now is None else now

      # Fail closed: the secret must be the whsec_<64 hex> shape (32 raw bytes)
      # -- never proceed with an empty, truncated, or wrong-type secret.
      if not isinstance(secret, str) or not _SECRET_PATTERN.fullmatch(secret):
          return False
      if not isinstance(header, str) or 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.fullmatch(t):
          return False
      if not _SIGNATURE_HEX_PATTERN.fullmatch(s):
          return False

      timestamp = int(t)
      if abs(now - timestamp) > tolerance_seconds:
          return False

      key = bytes.fromhex(secret[len("whsec_"):])

      if isinstance(raw_body, str):
          raw_body = raw_body.encode("utf-8")
      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))


  # Example: a Flask-style raw-body handler.
  # @app.post("/webhooks/radiumone")
  # def webhook():
  #     ok = verify_webhook_signature(
  #         secret=os.environ["RADIUMONE_WEBHOOK_SECRET"],
  #         header=request.headers.get("X-RadiumOne-Signature", ""),
  #         raw_body=request.get_data(),  # raw bytes, NOT request.json
  #     )
  #     if not ok:
  #         return "", 401
  #     return "", 200
  ```

  ```php PHP theme={null}
  <?php
  declare(strict_types=1);

  /**
   * Verify a RadiumOne webhook signature. PHP 8.1+, no framework.
   *
   * Key: hex-decode the part AFTER the `whsec_` prefix -> 32 raw bytes (this
   * differs from the HPP redirect signature, which uses the full secret
   * STRING as the HMAC key -- see verify-redirect-signature).
   * Signing string: "{v}.{t}." + the raw request body bytes (exactly as
   * received, before any JSON re-decoding).
   * Header shape: X-RadiumOne-Signature: v=1,t=<unix>,k=<8hex>,s=<64hex>.
   * `k` is advisory only -- never fetch/derive a key from it.
   *
   * Fails closed: a malformed secret, a malformed/missing header, a
   * non-integer timestamp, or any unrecognized `v` all return false rather
   * than throwing or silently signing with an empty/wrong-shaped key.
   */
  function verifyWebhookSignature(string $secret, string $header, string $rawBody, int $toleranceSeconds = 300, ?int $now = null): bool
  {
      $now ??= time();

      // Fail closed: the secret must be the whsec_<64 hex> shape (32 raw bytes)
      // -- never proceed with an empty, truncated, or wrong-type secret.
      // `D` anchors `$` to the absolute end of the subject -- without it PCRE
      // lets `$` match just before a single trailing "\n", so a secret/signature
      // sourced from a file with a trailing newline would wrongly pass shape
      // validation here and only fail later (or not at all).
      if (!preg_match('/^whsec_[0-9a-fA-F]{64}$/D', $secret)) {
          return false;
      }
      if ($header === '') {
          return false;
      }

      $fields = [];
      foreach (explode(',', $header) as $part) {
          $pos = strpos($part, '=');
          if ($pos === false) {
              return false;
          }
          $fields[substr($part, 0, $pos)] = substr($part, $pos + 1);
      }

      $v = $fields['v'] ?? null;
      $t = $fields['t'] ?? null;
      $s = $fields['s'] ?? null;
      if ($v === null || $t === null || $s === null || $v === '' || $t === '' || $s === '') {
          return false;
      }
      if ($v !== '1') {
          return false;
      }
      if (!ctype_digit($t)) {
          return false;
      }
      if (!preg_match('/^[0-9a-fA-F]{64}$/D', $s)) {
          return false;
      }

      $timestamp = (int) $t;
      if (abs($now - $timestamp) > $toleranceSeconds) {
          return false;
      }

      $key = hex2bin(substr($secret, strlen('whsec_')));

      $signingInput = "{$v}.{$t}." . $rawBody;
      $expectedHex = hash_hmac('sha256', $signingInput, $key);

      return hash_equals($expectedHex, strtolower($s));
  }

  // Example: a plain PHP handler reading the raw POST body.
  // $rawBody = file_get_contents('php://input');
  // $header = $_SERVER['HTTP_X_RADIUMONE_SIGNATURE'] ?? '';
  // if (!verifyWebhookSignature(getenv('RADIUMONE_WEBHOOK_SECRET'), $header, $rawBody)) {
  //     http_response_code(401);
  //     exit;
  // }
  ```

  ```java Java theme={null}
  import java.nio.charset.StandardCharsets;
  import java.util.HashMap;
  import java.util.Map;
  import java.util.regex.Pattern;
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;

  /**
   * Verify a RadiumOne webhook signature. Java 17+, no framework
   * ({@code javax.crypto.Mac} only).
   *
   * Key: hex-decode the part AFTER the {@code whsec_} prefix -> 32 raw bytes
   * (this differs from the HPP redirect signature, which uses the full
   * secret STRING as the HMAC key -- see verify-redirect-signature).
   * Signing string: {@code "{v}.{t}."} + the raw request body bytes (exactly
   * as received, before any JSON re-serialization).
   * Header shape: {@code X-RadiumOne-Signature: v=1,t=<unix>,k=<8hex>,s=<64hex>}.
   * {@code k} is advisory only -- never fetch/derive a key from it.
   *
   * Fails closed: a malformed secret, a malformed/missing header, a
   * non-integer timestamp, or any unrecognized {@code v} all return false
   * rather than throwing or silently signing with an empty/wrong-shaped key.
   */
  public final class VerifyWebhookSignature {

      private static final Pattern SECRET_PATTERN = Pattern.compile("^whsec_[0-9a-fA-F]{64}$");
      private static final Pattern SIGNATURE_HEX_PATTERN = Pattern.compile("^[0-9a-fA-F]{64}$");
      private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("^\\d+$");

      private VerifyWebhookSignature() {
      }

      public static boolean verify(String secret, String header, byte[] rawBody, int toleranceSeconds, Long now) {
          long nowSeconds = now != null ? now : System.currentTimeMillis() / 1000L;

          // Fail closed: the secret must be the whsec_<64 hex> shape (32 raw
          // bytes) -- never proceed with an empty, truncated, or wrong-type secret.
          if (secret == null || !SECRET_PATTERN.matcher(secret).matches()) {
              return false;
          }
          if (header == null || header.isEmpty()) {
              return false;
          }

          Map<String, String> fields = new HashMap<>();
          for (String part : header.split(",")) {
              int idx = part.indexOf('=');
              if (idx < 0) {
                  return false;
              }
              fields.put(part.substring(0, idx), part.substring(idx + 1));
          }

          String v = fields.get("v");
          String t = fields.get("t");
          String s = fields.get("s");
          if (v == null || v.isEmpty() || t == null || t.isEmpty() || s == null || s.isEmpty()) {
              return false;
          }
          if (!"1".equals(v)) {
              return false;
          }
          if (!TIMESTAMP_PATTERN.matcher(t).matches()) {
              return false;
          }
          if (!SIGNATURE_HEX_PATTERN.matcher(s).matches()) {
              return false;
          }

          // TIMESTAMP_PATTERN only bounds the shape (all digits), not the
          // length -- a 20+ digit value overflows a long and must be rejected,
          // not thrown, same as every other malformed-input path here.
          long timestamp;
          try {
              timestamp = Long.parseLong(t);
          } catch (NumberFormatException e) {
              return false;
          }
          if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) {
              return false;
          }

          byte[] key = hexToBytes(secret.substring("whsec_".length()));

          byte[] prefix = (v + "." + t + ".").getBytes(StandardCharsets.UTF_8);
          byte[] signingInput = new byte[prefix.length + rawBody.length];
          System.arraycopy(prefix, 0, signingInput, 0, prefix.length);
          System.arraycopy(rawBody, 0, signingInput, prefix.length, rawBody.length);

          byte[] expected;
          try {
              Mac mac = Mac.getInstance("HmacSHA256");
              mac.init(new SecretKeySpec(key, "HmacSHA256"));
              expected = mac.doFinal(signingInput);
          } catch (Exception e) {
              return false;
          }

          byte[] actual = hexToBytes(s);
          return java.security.MessageDigest.isEqual(expected, actual);
      }

      private static byte[] hexToBytes(String hex) {
          byte[] out = new byte[hex.length() / 2];
          for (int i = 0; i < out.length; i++) {
              out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
          }
          return out;
      }

      // Example: a plain servlet reading the raw request body before any JSON parsing.
      // boolean ok = VerifyWebhookSignature.verify(
      //     System.getenv("RADIUMONE_WEBHOOK_SECRET"),
      //     request.getHeader("X-RadiumOne-Signature"),
      //     rawBodyBytes,
      //     300,
      //     null);
      // if (!ok) { response.sendError(401); return; }
  }
  ```
</CodeGroup>

<Info>
  Read the raw body before any JSON parsing — most frameworks parse the request body
  automatically, which discards the exact bytes the signature was computed over. See
  [Build your handler](/payments-api/webhooks/overview#build-your-handler) for the full handler
  order (raw body → verify → dedupe → enqueue → respond).
</Info>

## Known-answer test

RadiumOne's own signer is tested against known-answer vectors, using the same dummy
secret (never a real key). Run them against your own verifier to confirm you've
implemented the algorithm correctly before going live. A representative subset:

| Case | Current time | Expected result |
| - | - | - |
| Valid signature within the freshness window | `1700000000` | Valid |
| Tampered body (same header, different body) | `1700000000` | Invalid |
| Expired timestamp (901 seconds past `t`, outside a 300s window) | `1700000901` | Invalid |
| Unrecognized scheme version (`v=2`), even with a correctly-computed HMAC | `1700000000` | Invalid |
| Wrong-type, empty, or malformed secret (wrong prefix, wrong case, no hex) | — | Invalid |
| Missing signature header, or a header missing a required field | — | Invalid |
| Malformed (non-integer) timestamp | — | Invalid |

The valid case uses secret `whsec_0000000000000000000000000000000000000000000000000000000000000000` and header `v=1,t=1700000000,k=66687aad,s=e4cd0b87e9dd8eeb3390b2d4e7d35b07480bfab5a1d687bfe055fd7337a7b119`. The full vector set — including tampered, expired, wrong-version, and malformed/forged cases — is tested against real Node.js, Python, PHP, and Java implementations of this algorithm before every release.

## Tolerance and replay

* A 5-minute (300 second) tolerance on `t` is the reference default — narrower windows increase the chance a slow network rejects a legitimate delivery; wider windows extend how long a captured signature stays replayable.
* Dedupe on the event `id` regardless of your tolerance window — a retried delivery (see [Retries, ordering, and duplicates](/payments-api/webhooks/retries-and-ordering)) is a legitimate re-send with a fresh, valid signature, not a replay.

## Rotating your secret

Rotating your webhook secret is a support-assisted operation today —
&#x20;see [Register your endpoint](/payments-api/webhooks/overview#register-your-endpoint).
There's **no automatic overlap window on RadiumOne's side**: once your secret is
rotated, the previous secret is irrecoverable. Deliveries already queued before the
rotation, though, may still be **retried using the old signature** for as long as they
keep retrying (up to the full backoff schedule, about 30 hours — see
[Retries, ordering, and duplicates](/payments-api/webhooks/retries-and-ordering)). If you want those
in-flight retries to keep verifying, keep your old secret available in your verifier
for that same window after you rotate, then remove it.

## Next steps

<Columns cols={2}>
  <Card title="Webhooks overview" icon="webhook" href="/payments-api/webhooks/overview">
    Handler steps: verify, dedupe, enqueue, respond.
  </Card>

  <Card title="Webhook event types" icon="list-ordered" href="/payments-api/webhooks/event-types">
    Every event and its payload shape.
  </Card>
</Columns>
