> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs-v3.openfx.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook authentication

> HMAC-SHA256 signatures in `X-OpenFX-Signature`. Verify every event server-side with a timestamp check and constant-time comparison.

Every webhook delivery is signed with **HMAC-SHA256** using your organization's webhook signing secret. The signature is delivered in the `X-OpenFX-Signature` header. Verify the signature — and the timestamp it carries — on every event before trusting the payload.

## Signature header format

The `X-OpenFX-Signature` header has the following format:

```http theme={null}
X-OpenFX-Signature: t=<unix-timestamp>,v1=<hex-encoded-hmac>
```

* **`t`** — Unix timestamp (seconds) of the delivery. Verify this is within an acceptable window (5 minutes is recommended) to prevent replay attacks.
* **`v1`** — hex-encoded HMAC-SHA256 of the signed payload (see below).

The signed payload is computed by concatenating the timestamp, a literal `.`, and the raw request body:

```text theme={null}
signed_payload = timestamp + "." + raw_body
hmac = HMAC_SHA256(signed_payload, signing_secret)
```

Each delivery also carries an **`X-Trace-Id`** header containing an opaque trace identifier. Include this value when contacting support to help correlate deliveries in our logs.

The backend additionally sends a legacy **`X-REDENVELOPE-SIGNATURE`** header containing an HMAC computed with the same signing secret. It is a deprecated alias kept for backward compatibility — verify `X-OpenFX-Signature` instead.

## How verification works

<Steps>
  <Step title="Capture the raw request body">
    Read the body as the exact bytes you received it. Do **not** parse-then-reserialize JSON — whitespace and key ordering matter, and a re-serialized body will not match the signature.
  </Step>

  <Step title="Parse the signature header">
    Split `X-OpenFX-Signature` on `,` and extract the `t` and `v1` values. Reject the delivery immediately if either is absent.
  </Step>

  <Step title="Check the replay window">
    Compare the `t` timestamp against the current time. Reject deliveries where `|now - t| > 300` seconds (5 minutes). This prevents replayed deliveries from being accepted.
  </Step>

  <Step title="Compute the expected HMAC">
    Build the signed payload as `t + "." + raw_body`. Run `HMAC_SHA256(signed_payload, signing_secret)` and **hex-encode** the digest. The signing secret is the value labeled `Signing key` in your dashboard webhook settings.
  </Step>

  <Step title="Compare in constant time">
    Compare your computed HMAC against the `v1` value from the header using a **constant-time** comparator. Naive `==` / `===` comparison is timing-attack-vulnerable.
  </Step>

  <Step title="Reject on mismatch">
    Respond with `401 Unauthorized` (or `400 Bad Request`) and do **not** call your handler. Never trust the payload before verification succeeds.
  </Step>
</Steps>

## Verification samples

<CodeGroup>
  ```javascript Node theme={null}
  // Verifies the HMAC-SHA256 signature OpenFX attaches to every webhook
  // delivery in X-OpenFX-Signature — the pattern from
  // /v3/webhooks/authentication#how-verification-works.
  const crypto = require("node:crypto");
  // Mints the signature OpenFX's server would attach. Only used here to
  // produce a realistic round-trip demo — you never compute this yourself
  // against real deliveries, only verify what you received.
  function signPayload(rawBody, signingSecret, timestamp) {
    const signedPayload = `${timestamp}.${rawBody}`;
    const hmac = crypto.createHmac("sha256", signingSecret).update(signedPayload).digest("hex");
    return `t=${timestamp},v1=${hmac}`;
  }
  // Verifies a delivery. `now` defaults to the real clock but can be
  // overridden so the replay-window check is testable without waiting on
  // real time.
  function isValidSignature(rawBody, signatureHeader, signingSecret, now = Date.now() / 1000) {
    // Parse "t=<unix>,v1=<hex>"
    const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
    const timestamp = parts["t"];
    const receivedHmac = parts["v1"];
    if (!timestamp || !receivedHmac) return false;
    // Replay window: reject events older than 5 minutes.
    if (Math.abs(now - Number(timestamp)) > 300) return false;
    const signedPayload = `${timestamp}.${rawBody}`;
    const expected = crypto.createHmac("sha256", signingSecret).update(signedPayload).digest("hex");
    // Constant-time comparison. Buffer lengths must match.
    const a = Buffer.from(receivedHmac);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  module.exports = { signPayload, isValidSignature };
  if (require.main === module) {
    const signingSecret = "whsec_docs_demo_secret";
    const rawBody = JSON.stringify({ id: "evt_123", type: "deposits" });
    const now = Math.floor(Date.now() / 1000);
    const header = signPayload(rawBody, signingSecret, now);
    console.log("X-OpenFX-Signature:", header);
    console.log("valid:", isValidSignature(rawBody, header, signingSecret));
  }
  ```

  ```python Python theme={null}
  """Verifies the HMAC-SHA256 signature OpenFX attaches to every webhook
  delivery in X-OpenFX-Signature — the pattern from
  /v3/webhooks/authentication#how-verification-works."""
  import hashlib
  import hmac
  import time
  def sign_payload(raw_body: str, signing_secret: str, timestamp: int) -> str:
      """Mints the signature OpenFX's server would attach. Only used here to
      produce a realistic round-trip demo — you never compute this yourself
      against real deliveries, only verify what you received."""
      signed_payload = f"{timestamp}.{raw_body}".encode()
      digest = hmac.new(signing_secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
      return f"t={timestamp},v1={digest}"
  def is_valid_signature(raw_body: str, signature_header: str, signing_secret: str, now: float = None) -> bool:
      """Verifies a delivery. `now` defaults to the real clock but can be
      overridden so the replay-window check is testable without waiting on
      real time."""
      if now is None:
          now = time.time()
      # Parse "t=<unix>,v1=<hex>"
      parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
      timestamp = parts.get("t")
      received_hmac = parts.get("v1")
      if not timestamp or not received_hmac:
          return False
      # Replay window: reject events older than 5 minutes. A malformed
      # timestamp (attacker-controlled) must be rejected, not raised.
      try:
          parsed_timestamp = float(timestamp)
      except ValueError:
          return False
      if abs(now - parsed_timestamp) > 300:
          return False
      signed_payload = f"{timestamp}.{raw_body}".encode()
      expected = hmac.new(signing_secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
      # Constant-time comparison
      return hmac.compare_digest(received_hmac, expected)
  if __name__ == "__main__":
      import json
      signing_secret = "whsec_docs_demo_secret"
      raw_body = json.dumps({"id": "evt_123", "type": "deposits"})
      now = int(time.time())
      header = sign_payload(raw_body, signing_secret, now)
      print("X-OpenFX-Signature:", header)
      print("valid:", is_valid_signature(raw_body, header, signing_secret))
  ```

  ```java Java theme={null}
  // Verifies the HMAC-SHA256 signature OpenFX attaches to every webhook
  // delivery in X-OpenFX-Signature — the pattern from
  // /v3/webhooks/authentication#how-verification-works. Uses only the
  // built-in JDK APIs — no external dependency.
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;
  import java.time.Instant;
  import java.util.HashMap;
  import java.util.Map;
  public class VerifySignature {
      private static final int REPLAY_WINDOW_SECONDS = 300;
      private static String hmacHex(String payload, String signingSecret) throws Exception {
          Mac mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
          byte[] hmacBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
          StringBuilder sb = new StringBuilder();
          for (byte b : hmacBytes) sb.append(String.format("%02x", b));
          return sb.toString();
      }
      // Mints the signature OpenFX's server would attach. Only used here to
      // produce a realistic round-trip demo — you never compute this
      // yourself against real deliveries, only verify what you received.
      static String signPayload(String rawBody, String signingSecret, long timestamp) throws Exception {
          String signedPayload = timestamp + "." + rawBody;
          return "t=" + timestamp + ",v1=" + hmacHex(signedPayload, signingSecret);
      }
      // Verifies a delivery, checking the replay window against `now`
      // (epoch seconds) so it's testable without waiting on real time.
      static boolean isValidSignature(String rawBody, String signatureHeader, String signingSecret, long now)
              throws Exception {
          // Parse "t=<unix>,v1=<hex>"
          Map<String, String> parts = new HashMap<>();
          for (String seg : signatureHeader.split(",")) {
              String[] kv = seg.split("=", 2);
              if (kv.length == 2) parts.put(kv[0], kv[1]);
          }
          String timestamp = parts.get("t");
          String receivedHmac = parts.get("v1");
          if (timestamp == null || receivedHmac == null) return false;
          // Replay window. A malformed timestamp (attacker-controlled) must
          // be rejected, not raised.
          long ts;
          try {
              ts = Long.parseLong(timestamp);
          } catch (NumberFormatException e) {
              return false;
          }
          if (Math.abs(now - ts) > REPLAY_WINDOW_SECONDS) return false;
          String signedPayload = timestamp + "." + rawBody;
          String expected = hmacHex(signedPayload, signingSecret);
          // MessageDigest.isEqual is constant-time
          return MessageDigest.isEqual(
                  receivedHmac.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8));
      }
      public static void main(String[] args) throws Exception {
          String signingSecret = "whsec_docs_demo_secret";
          String rawBody = "{\"id\":\"evt_123\",\"type\":\"deposits\"}";
          long now = Instant.now().getEpochSecond();
          String header = signPayload(rawBody, signingSecret, now);
          System.out.println("X-OpenFX-Signature: " + header);
          System.out.println("valid: " + isValidSignature(rawBody, header, signingSecret, now));
      }
  }
  ```

  ```cpp C++ theme={null}
  // Verifies the HMAC-SHA256 signature OpenFX attaches to every webhook
  // delivery in X-OpenFX-Signature — the pattern from
  // /v3/webhooks/authentication#how-verification-works. Requires OpenSSL.
  #include <openssl/hmac.h>
  #include <cmath>
  #include <cstdlib>
  #include <iomanip>
  #include <map>
  #include <sstream>
  #include <string>
  static std::string hmacHex(const std::string& payload, const std::string& signingSecret) {
      unsigned char digest[EVP_MAX_MD_SIZE];
      unsigned int digestLen = 0;
      HMAC(EVP_sha256(), signingSecret.data(), static_cast<int>(signingSecret.size()),
           reinterpret_cast<const unsigned char*>(payload.data()), payload.size(), digest, &digestLen);
      std::ostringstream oss;
      for (unsigned int i = 0; i < digestLen; i++)
          oss << std::hex << std::setfill('0') << std::setw(2) << static_cast<int>(digest[i]);
      return oss.str();
  }
  static std::map<std::string, std::string> parseSignatureHeader(const std::string& header) {
      std::map<std::string, std::string> parts;
      size_t pos = 0;
      while (pos < header.size()) {
          size_t comma = header.find(',', pos);
          std::string segment = header.substr(pos, comma == std::string::npos ? std::string::npos : comma - pos);
          size_t eq = segment.find('=');
          if (eq != std::string::npos) parts[segment.substr(0, eq)] = segment.substr(eq + 1);
          if (comma == std::string::npos) break;
          pos = comma + 1;
      }
      return parts;
  }
  // Constant-time comparison — avoids leaking timing information about
  // where two strings first differ.
  static bool constantTimeEquals(const std::string& a, const std::string& b) {
      if (a.size() != b.size()) return false;
      unsigned char diff = 0;
      for (size_t i = 0; i < a.size(); i++) diff |= static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i]);
      return diff == 0;
  }
  // Mints the signature OpenFX's server would attach. Only used here to
  // produce a realistic round-trip demo — you never compute this yourself
  // against real deliveries, only verify what you received.
  std::string signPayload(const std::string& rawBody, const std::string& signingSecret, long timestamp) {
      std::string signedPayload = std::to_string(timestamp) + "." + rawBody;
      return "t=" + std::to_string(timestamp) + ",v1=" + hmacHex(signedPayload, signingSecret);
  }
  // Verifies a delivery, checking the replay window against `now` (epoch
  // seconds) so it's testable without waiting on real time.
  bool isValidSignature(const std::string& rawBody, const std::string& signatureHeader,
                         const std::string& signingSecret, long now) {
      auto parts = parseSignatureHeader(signatureHeader);
      auto tIt = parts.find("t");
      auto v1It = parts.find("v1");
      if (tIt == parts.end() || v1It == parts.end()) return false;
      long timestamp = std::atol(tIt->second.c_str());
      if (std::abs(now - timestamp) > 300) return false;
      std::string signedPayload = tIt->second + "." + rawBody;
      std::string expected = hmacHex(signedPayload, signingSecret);
      return constantTimeEquals(v1It->second, expected);
  }
  #ifndef VERIFY_SIGNATURE_NO_MAIN
  #include <chrono>
  #include <iostream>
  int main() {
      std::string signingSecret = "whsec_docs_demo_secret";
      std::string rawBody = R"({"id":"evt_123","type":"deposits"})";
      long now = std::chrono::duration_cast<std::chrono::seconds>(
                     std::chrono::system_clock::now().time_since_epoch())
                     .count();
      std::string header = signPayload(rawBody, signingSecret, now);
      std::cout << "X-OpenFX-Signature: " << header << std::endl;
      std::cout << "valid: " << (isValidSignature(rawBody, header, signingSecret, now) ? "true" : "false")
                 << std::endl;
      return 0;
  }
  #endif
  ```

  ```go Go theme={null}
  // Verifies the HMAC-SHA256 signature OpenFX attaches to every webhook
  // delivery in X-OpenFX-Signature — the pattern from
  // /v3/webhooks/authentication#how-verification-works.
  package main
  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"math"
  	"strconv"
  	"strings"
  	"time"
  )
  // Mints the signature OpenFX's server would attach. Only used here to
  // produce a realistic round-trip demo — you never compute this yourself
  // against real deliveries, only verify what you received.
  func signPayload(rawBody, signingSecret string, timestamp int64) string {
  	signedPayload := fmt.Sprintf("%d.%s", timestamp, rawBody)
  	mac := hmac.New(sha256.New, []byte(signingSecret))
  	mac.Write([]byte(signedPayload))
  	return fmt.Sprintf("t=%d,v1=%s", timestamp, hex.EncodeToString(mac.Sum(nil)))
  }
  // Verifies a delivery, checking the replay window against now (epoch
  // seconds) so it's testable without waiting on real time.
  func isValidSignature(rawBody, signatureHeader, signingSecret string, now int64) bool {
  	// Parse "t=<unix>,v1=<hex>"
  	parts := make(map[string]string)
  	for _, seg := range strings.Split(signatureHeader, ",") {
  		kv := strings.SplitN(seg, "=", 2)
  		if len(kv) == 2 {
  			parts[kv[0]] = kv[1]
  		}
  	}
  	timestamp, ok1 := parts["t"]
  	receivedHmac, ok2 := parts["v1"]
  	if !ok1 || !ok2 {
  		return false
  	}
  	// Replay window: reject events older than 5 minutes.
  	ts, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil || math.Abs(float64(now-ts)) > 300 {
  		return false
  	}
  	signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)
  	mac := hmac.New(sha256.New, []byte(signingSecret))
  	mac.Write([]byte(signedPayload))
  	expected := hex.EncodeToString(mac.Sum(nil))
  	// hmac.Equal is constant-time
  	return hmac.Equal([]byte(receivedHmac), []byte(expected))
  }
  func main() {
  	signingSecret := "whsec_docs_demo_secret"
  	rawBody := `{"id":"evt_123","type":"deposits"}`
  	now := time.Now().Unix()
  	header := signPayload(rawBody, signingSecret, now)
  	fmt.Println("X-OpenFX-Signature:", header)
  	fmt.Println("valid:", isValidSignature(rawBody, header, signingSecret, now))
  }
  ```
</CodeGroup>

<Note>
  **Reading the raw body:** every framework that does JSON body parsing before your handler runs is a footgun. In Express, use `express.raw({ type: 'application/json' })`; in Flask, `request.get_data()` before any `.get_json()`; in Go, read `r.Body` before the framework consumes it. The raw bytes are not optional.
</Note>

## Where the signing secret comes from

* **Live:** download from the dashboard webhook settings. The signing secret has no environment prefix — it begins with `whsec_` (e.g. `whsec_...`).
* **Sandbox:** download separately from the Sandbox dashboard. Sandbox secrets carry a `sandbox_` prefix (e.g. `sandbox_whsec_...`).
* **Rotation:** generate the new secret, deploy your verifier to accept both old and new for at least one delivery cycle (allowing in-flight deliveries to clear), then revoke the old. Treat the secret like a Live credential — store in a secrets manager, never check into source.

<Warning>
  **Sandbox and Live secrets are different.** A handler using the wrong
  environment's secret will reject every event. Wire the secret from
  environment-aware config, not a hard-coded constant.
</Warning>

## Additional delivery headers

| Header                     | Description                                                                                                                               |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OpenFX-Signature`       | HMAC-SHA256 signature with embedded timestamp (`t=<unix>,v1=<hex>`). Verify this on every delivery.                                       |
| `X-OpenFX-Webhook-Version` | Webhook payload contract version, currently `v3`. Lets your handler branch if the envelope shape changes in a future major version.       |
| `X-REDENVELOPE-SIGNATURE`  | Legacy/deprecated signature header, computed with the same signing secret. Kept for backward compatibility — prefer `X-OpenFX-Signature`. |
| `X-Trace-Id`               | Opaque trace identifier for this delivery. Include when contacting support for delivery-level debugging.                                  |
| `Content-Type`             | Always `application/json`.                                                                                                                |

## Common mistakes

* **Parsing then re-serializing the body before computing the HMAC.** Whitespace and key ordering differ; the signature will never match. Capture the raw bytes first.
* **Skipping the replay-window check.** Verifying only the HMAC without checking the timestamp leaves your handler open to replay attacks. Check `|now - t| <= 300`.
* **Using `==` / `===` for comparison.** Timing-attack-vulnerable. Use `crypto.timingSafeEqual` (Node), `hmac.compare_digest` (Python), `hmac.Equal` (Go), `MessageDigest.isEqual` (Java), a manually constant-time byte comparison (C++, which has no built-in one).
* **Hex vs base64 encoding.** The `v1` signature value is **hex-encoded**; do not base64-decode it.
* **Confusing the API key with the webhook signing secret.** They are two different secrets, both downloadable from the dashboard. The webhook signing secret does not appear in any API request — it only signs deliveries to you.
* **Trusting payload contents before signature verification.** Reject unsigned or mis-signed events with a 401 before reading any field of the body.

## What's next

<CardGroup cols={2}>
  <Card title="Deposit webhooks" icon="arrow-down-to-bracket" href="/v3/webhooks/deposits">
    Event shape for fiat and stablecoin deposits.
  </Card>

  <Card title="Withdrawal webhooks" icon="arrow-up-from-bracket" href="/v3/webhooks/withdrawals">
    Event shape for withdrawals, including the typed `settlement` receipt on
    completed wires.
  </Card>

  <Card title="Deposit lifecycle" icon="arrow-down-to-bracket" href="/v3/deposit-lifecycle">
    How deposits transition from pending to completed.
  </Card>

  <Card title="Withdrawal lifecycle" icon="arrow-up-from-bracket" href="/v3/withdrawal-lifecycle">
    How withdrawals move through processing.
  </Card>
</CardGroup>
