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

# Webhook authentication

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

Every webhook delivery is signed with **HMAC-SHA256** over a timestamp-prefixed payload, using your organization's webhook signing secret. The signature is delivered in the `X-OpenFX-Signature` header, alongside the timestamp it was computed with. Verify both the signature and the timestamp's age on every event before trusting the payload.

## Signature header format

The `X-OpenFX-Signature` header carries two comma-separated `key=value` fields: `t`, the Unix timestamp (seconds) the signature was computed at, and `v1`, the hex-encoded HMAC-SHA256 digest:

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

```text theme={"theme":{"light":"poimandres","dark":"poimandres"}}
signed_payload = "{timestamp}.{raw_body}"
v1 = hex(HMAC_SHA256(signed_payload, signing_secret))
```

The timestamp is concatenated with the raw body using a literal `.` separator before hashing. Including it in the signed payload (not just alongside it) means an attacker who captures a valid delivery can't replay it later with a different `t`. The timestamp is authenticated, not just present.

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.

## 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 each part on `=` to recover `t` and
    `v1`. Reject the delivery if either field is missing.
  </Step>

  <Step title="Reject stale timestamps">
    Reject if `|now - t| > 300` seconds (5 minutes). This bounds how long a
    captured delivery remains replayable, so clock drift beyond a few minutes
    between your server and OpenFX will start rejecting legitimate
    deliveries. Keep your server clock synced (NTP).
  </Step>

  <Step title="Compute the expected HMAC">
    Run `HMAC_SHA256("{t}.{raw_body}", 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 digest against `v1` 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 both the timestamp and
    signature check succeed.
  </Step>
</Steps>

## Verification samples

<CodeGroup>
  ```javascript Node theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // Verifies the timestamped HMAC-SHA256 signature OpenFX attaches to every
  // webhook delivery in X-OpenFX-Signature, per the pattern from
  // /v3/webhooks/authentication#how-verification-works.
  const crypto = require("node:crypto");
  const TOLERANCE_SECONDS = 300;
  // Mints the signature OpenFX's server would attach: t=<unix-timestamp>,
  // v1=<hex-hmac> over "{timestamp}.{raw_body}". 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 = Math.floor(Date.now() / 1000),
  ) {
    const hmac = crypto
      .createHmac("sha256", signingSecret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    return `t=${timestamp},v1=${hmac}`;
  }
  // Splits "t=<ts>,v1=<hex>" into { timestamp, hmac }, or null if either field
  // is missing.
  function parseSignatureHeader(signatureHeader) {
    const fields = Object.fromEntries(
      (signatureHeader || "")
        .split(",")
        .map((part) => part.split("="))
        .filter(([key, value]) => key && value),
    );
    if (!fields.t || !fields.v1) return null;
    return { timestamp: Number(fields.t), hmac: fields.v1 };
  }
  // Verifies a delivery: reject a stale timestamp, then recompute the HMAC
  // over "{timestamp}.{raw_body}" and compare it against v1 in constant time.
  function isValidSignature(
    rawBody,
    signatureHeader,
    signingSecret,
    now = Math.floor(Date.now() / 1000),
  ) {
    const parsed = parseSignatureHeader(signatureHeader);
    if (!parsed) return false;
    const { timestamp, hmac } = parsed;
    if (
      !Number.isFinite(timestamp) ||
      Math.abs(now - timestamp) > TOLERANCE_SECONDS
    ) {
      return false;
    }
    const expected = crypto
      .createHmac("sha256", signingSecret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    // Constant-time comparison. Buffer lengths must match.
    const a = Buffer.from(hmac);
    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 signature = signPayload(rawBody, signingSecret);
    console.log("X-OpenFX-Signature:", signature);
    console.log("valid:", isValidSignature(rawBody, signature, signingSecret));
  }
  ```

  ```python Python theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  """Verifies the timestamped HMAC-SHA256 signature OpenFX attaches to every
  webhook delivery in X-OpenFX-Signature, per the pattern from
  /v3/webhooks/authentication#how-verification-works."""
  import hashlib
  import hmac
  import time
  TOLERANCE_SECONDS = 300
  def sign_payload(raw_body: str, signing_secret: str, timestamp: int = None) -> str:
      """Mints the signature OpenFX's server would attach: t=<unix-timestamp>,
      v1=<hex-hmac> over "{timestamp}.{raw_body}". Only used here to produce a
      realistic round-trip demo. You never compute this yourself against real
      deliveries, only verify what you received."""
      if timestamp is None:
          timestamp = int(time.time())
      signed_payload = f"{timestamp}.{raw_body}"
      digest = hmac.new(signing_secret.encode("utf-8"), signed_payload.encode("utf-8"), hashlib.sha256).hexdigest()
      return f"t={timestamp},v1={digest}"
  def _parse_signature_header(signature_header: str):
      """Splits "t=<ts>,v1=<hex>" into (timestamp, hmac), or None if either
      field is missing."""
      fields = {}
      for part in (signature_header or "").split(","):
          if "=" not in part:
              continue
          key, _, value = part.partition("=")
          if key and value:
              fields[key] = value
      if "t" not in fields or "v1" not in fields:
          return None
      try:
          return int(fields["t"]), fields["v1"]
      except ValueError:
          return None
  def is_valid_signature(
      raw_body: str, signature_header: str, signing_secret: str, now: int = None
  ) -> bool:
      """Verifies a delivery: reject a stale timestamp, then recompute the HMAC
      over "{timestamp}.{raw_body}" and compare it against v1 in constant
      time."""
      if now is None:
          now = int(time.time())
      parsed = _parse_signature_header(signature_header)
      if parsed is None:
          return False
      timestamp, received_hmac = parsed
      if abs(now - timestamp) > TOLERANCE_SECONDS:
          return False
      signed_payload = f"{timestamp}.{raw_body}"
      expected = hmac.new(signing_secret.encode("utf-8"), signed_payload.encode("utf-8"), 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"})
      signature = sign_payload(raw_body, signing_secret)
      print("X-OpenFX-Signature:", signature)
      print("valid:", is_valid_signature(raw_body, signature, signing_secret))
  ```

  ```java Java theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // Verifies the timestamped HMAC-SHA256 signature OpenFX attaches to every
  // webhook delivery in X-OpenFX-Signature, per 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.util.HexFormat;
  import java.util.Map;
  public class VerifySignature {
      static final long TOLERANCE_SECONDS = 300;
      // Mints the signature OpenFX's server would attach: t=<unix-timestamp>,
      // v1=<hex-hmac> over "{timestamp}.{raw_body}". 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 hmacHex = hmacHex(timestamp + "." + rawBody, signingSecret);
          return "t=" + timestamp + ",v1=" + hmacHex;
      }
      static String signPayload(String rawBody, String signingSecret) throws Exception {
          return signPayload(rawBody, signingSecret, System.currentTimeMillis() / 1000);
      }
      static String hmacHex(String message, String signingSecret) throws Exception {
          Mac mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
          byte[] hmacBytes = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
          return HexFormat.of().formatHex(hmacBytes);
      }
      // Splits "t=<ts>,v1=<hex>" into its fields, or null if either is missing.
      static Map<String, String> parseSignatureHeader(String signatureHeader) {
          if (signatureHeader == null) return null;
          Map<String, String> fields = new java.util.HashMap<>();
          for (String part : signatureHeader.split(",")) {
              String[] kv = part.split("=", 2);
              if (kv.length == 2 && !kv[0].isEmpty() && !kv[1].isEmpty()) {
                  fields.put(kv[0], kv[1]);
              }
          }
          if (!fields.containsKey("t") || !fields.containsKey("v1")) return null;
          return fields;
      }
      // Verifies a delivery: reject a stale timestamp, then recompute the
      // HMAC over "{timestamp}.{raw_body}" and compare it against v1 in
      // constant time.
      static boolean isValidSignature(String rawBody, String signatureHeader, String signingSecret, long now)
              throws Exception {
          Map<String, String> parsed = parseSignatureHeader(signatureHeader);
          if (parsed == null) return false;
          long timestamp;
          try {
              timestamp = Long.parseLong(parsed.get("t"));
          } catch (NumberFormatException e) {
              return false;
          }
          if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
          String expected = hmacHex(timestamp + "." + rawBody, signingSecret);
          // MessageDigest.isEqual is constant-time
          return MessageDigest.isEqual(
                  parsed.get("v1").getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8));
      }
      static boolean isValidSignature(String rawBody, String signatureHeader, String signingSecret) throws Exception {
          return isValidSignature(rawBody, signatureHeader, signingSecret, System.currentTimeMillis() / 1000);
      }
      public static void main(String[] args) throws Exception {
          String signingSecret = "whsec_docs_demo_secret";
          String rawBody = "{\"id\":\"evt_123\",\"type\":\"deposits\"}";
          String signature = signPayload(rawBody, signingSecret);
          System.out.println("X-OpenFX-Signature: " + signature);
          System.out.println("valid: " + isValidSignature(rawBody, signature, signingSecret));
      }
  }
  ```

  ```cpp C++ theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // Verifies the timestamped HMAC-SHA256 signature OpenFX attaches to every
  // webhook delivery in X-OpenFX-Signature, per the pattern from
  // /v3/webhooks/authentication#how-verification-works. Requires OpenSSL.
  #include <openssl/evp.h>
  #include <openssl/hmac.h>
  #include <cstdint>
  #include <cstdlib>
  #include <sstream>
  #include <string>
  namespace {
  constexpr long long kToleranceSeconds = 300;
  std::string hmacHex(const std::string& message, 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*>(message.data()), message.size(), digest, &digestLen);
      static const char* kHex = "0123456789abcdef";
      std::string hex(digestLen * 2, '0');
      for (unsigned int i = 0; i < digestLen; i++) {
          hex[2 * i] = kHex[(digest[i] >> 4) & 0xF];
          hex[2 * i + 1] = kHex[digest[i] & 0xF];
      }
      return hex;
  }
  }  // namespace
  // Mints the signature OpenFX's server would attach: t=<unix-timestamp>,
  // v1=<hex-hmac> over "{timestamp}.{raw_body}". 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 long timestamp) {
      std::string signedPayload = std::to_string(timestamp) + "." + rawBody;
      return "t=" + std::to_string(timestamp) + ",v1=" + hmacHex(signedPayload, signingSecret);
  }
  // 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;
  }
  // Splits "t=<ts>,v1=<hex>" into its fields. Returns false if either field is
  // missing or the timestamp doesn't parse.
  static bool parseSignatureHeader(const std::string& signatureHeader, long long& timestamp, std::string& hmac) {
      std::string t, v1;
      std::stringstream stream(signatureHeader);
      std::string part;
      while (std::getline(stream, part, ',')) {
          size_t eq = part.find('=');
          if (eq == std::string::npos || eq == 0 || eq == part.size() - 1) continue;
          std::string key = part.substr(0, eq);
          std::string value = part.substr(eq + 1);
          if (key == "t") t = value;
          if (key == "v1") v1 = value;
      }
      if (t.empty() || v1.empty()) return false;
      char* end = nullptr;
      timestamp = std::strtoll(t.c_str(), &end, 10);
      if (end == t.c_str() || *end != '\0') return false;
      hmac = v1;
      return true;
  }
  // Verifies a delivery: reject a stale timestamp, then recompute the HMAC
  // over "{timestamp}.{raw_body}" and compare it against v1 in constant time.
  bool isValidSignature(const std::string& rawBody, const std::string& signatureHeader,
                         const std::string& signingSecret, long long now) {
      long long timestamp = 0;
      std::string receivedHmac;
      if (!parseSignatureHeader(signatureHeader, timestamp, receivedHmac)) return false;
      long long diff = now - timestamp;
      if (diff > kToleranceSeconds || diff < -kToleranceSeconds) return false;
      std::string signedPayload = std::to_string(timestamp) + "." + rawBody;
      std::string expected = hmacHex(signedPayload, signingSecret);
      return constantTimeEquals(receivedHmac, expected);
  }
  #ifndef VERIFY_SIGNATURE_NO_MAIN
  #include <ctime>
  #include <iostream>
  int main() {
      std::string signingSecret = "whsec_docs_demo_secret";
      std::string rawBody = R"({"id":"evt_123","type":"deposits"})";
      long long now = static_cast<long long>(std::time(nullptr));
      std::string signature = signPayload(rawBody, signingSecret, now);
      std::cout << "X-OpenFX-Signature: " << signature << std::endl;
      std::cout << "valid: " << (isValidSignature(rawBody, signature, signingSecret, now) ? "true" : "false")
                 << std::endl;
      return 0;
  }
  #endif
  ```

  ```go Go theme={"theme":{"light":"poimandres","dark":"poimandres"}}
  // Verifies the timestamped HMAC-SHA256 signature OpenFX attaches to every
  // webhook delivery in X-OpenFX-Signature, per the pattern from
  // /v3/webhooks/authentication#how-verification-works.
  package main
  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"strconv"
  	"strings"
  	"time"
  )
  const toleranceSeconds = 300
  // Mints the signature OpenFX's server would attach: t=<unix-timestamp>,
  // v1=<hex-hmac> over "{timestamp}.{raw_body}". 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)))
  }
  // parseSignatureHeader splits "t=<ts>,v1=<hex>" into its fields. ok is false
  // if either field is missing or the timestamp doesn't parse.
  func parseSignatureHeader(signatureHeader string) (timestamp int64, hmacHex string, ok bool) {
  	fields := map[string]string{}
  	for _, part := range strings.Split(signatureHeader, ",") {
  		key, value, found := strings.Cut(part, "=")
  		if found && key != "" && value != "" {
  			fields[key] = value
  		}
  	}
  	t, hasT := fields["t"]
  	v1, hasV1 := fields["v1"]
  	if !hasT || !hasV1 {
  		return 0, "", false
  	}
  	parsed, err := strconv.ParseInt(t, 10, 64)
  	if err != nil {
  		return 0, "", false
  	}
  	return parsed, v1, true
  }
  // Verifies a delivery: reject a stale timestamp, then recompute the HMAC
  // over "{timestamp}.{raw_body}" and compare it against v1 in constant time.
  func isValidSignature(rawBody, signatureHeader, signingSecret string, now int64) bool {
  	timestamp, receivedHmac, ok := parseSignatureHeader(signatureHeader)
  	if !ok {
  		return false
  	}
  	if diff := now - timestamp; diff > toleranceSeconds || diff < -toleranceSeconds {
  		return false
  	}
  	signedPayload := fmt.Sprintf("%d.%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()
  	signature := signPayload(rawBody, signingSecret, now)
  	fmt.Println("X-OpenFX-Signature:", signature)
  	fmt.Println("valid:", isValidSignature(rawBody, signature, 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 key comes from

* **Live:** download from the dashboard webhook settings.
* **Sandbox:** download separately from the Sandbox dashboard.
* **Rotation:** generate the new key, 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 key like a Live credential: store in a secrets manager, never check into source.

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

## Additional delivery headers

| Header | Description |
| - | - |
| `X-OpenFX-Signature` | `t=<unix-timestamp>,v1=<hex-hmac>`: a hex-encoded HMAC-SHA256 of `{timestamp}.{raw_body}`, plus the timestamp it covers. Verify both the timestamp's age and the signature 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-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.
* **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 decoding.** The `v1` value is **hex-encoded**; do not base64-decode it.
* **Hashing the raw body alone.** The signed payload is `{timestamp}.{raw_body}`, not the raw body by itself. Reusing the pre-timestamp formula produces a digest that never matches `v1`.
* **Skipping the timestamp check.** Verifying `v1` without checking `|now - t| > 300` accepts a captured delivery replayed at any point in the future. Both checks are required.
* **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.
* **Assuming signature verification also dedups redeliveries.** It doesn't. Each retry carries a fresh timestamp, so it produces its own valid signature rather than repeating the original. Dedup on the event `id` separately; see [Common mistakes](/v3/webhooks/setup#common-mistakes) on the setup page.

## What's next

<CardGroup cols={2}>
  <Card title="Deposit webhooks" icon="https://public-assets.openfx.com/docs/icons/deposits.svg" href="/v3/webhooks/deposits/deposit-completed">
    Event shape for fiat and stablecoin deposits.
  </Card>

  <Card title="Withdrawal webhooks" icon="https://public-assets.openfx.com/docshttps://public-assets.openfx.com/docs/icons/withdrawals.svg" href="/v3/webhooks/withdrawals/withdrawal-completed">
    Event shape for withdrawals, including the typed `settlement` receipt on
    completed wires.
  </Card>

  <Card title="Payout webhooks" icon="landmark" href="/v3/webhooks/payments/payouts-webhooks/payout-created">
    Event shape for payout status changes.
  </Card>

  <Card title="Deposits" icon="https://public-assets.openfx.com/docs/icons/deposits.svg" href="/v3/deposits">
    How deposits transition from pending to completed.
  </Card>

  <Card title="Withdrawals" icon="https://public-assets.openfx.com/docs/icons/withdrawals.svg" href="/v3/withdrawals">
    How withdrawals move from <span class="ofx-enum">PENDING</span> to <span class="ofx-enum">COMPLETED</span> or <span class="ofx-enum">CANCELLED</span>.
  </Card>
</CardGroup>
