Every webhook delivery from OpenFX includes an X-OpenFX-Signature header containing an HMAC-SHA256 signature computed over the raw request body. Verifying this signature is the only way to confirm that the webhook was genuinely sent by OpenFX and was not altered in transit. Every delivery also includes an X-OpenFX-Event-Id header containing the event ID (evt_) associated with the delivery. Use this for deduplication and cross-referencing with the Events API (GET /events).
Always verify the signature before processing any webhook payload. Without verification, an attacker could send forged webhook payloads to your endpoint, potentially causing your application to process fake payment completions, fraudulent status changes, or other harmful events.

How it works

  1. When you create a webhook subscription, OpenFX generates a unique signing secret and returns it in the response. This secret is shared between OpenFX and your application.
  2. For each delivery, OpenFX computes HMAC-SHA256(signing_secret, raw_request_body) and includes the hex-encoded result in the X-OpenFX-Signature header.
  3. Your endpoint reads the raw request body, computes the same HMAC using the stored signing secret, and compares the result against the header value.
  4. If the values match, the payload is authentic. If not, reject it.
  OpenFX                                       Your Endpoint
  ======                                       =============

  1. Compute signature:
     HMAC-SHA256(secret, body)

  2. Send delivery:
     POST /your/webhook/url
     X-OpenFX-Signature: <hex_signature>
     Body: { "id": "whd_...", ... }    --->

                                                3. Read raw body
                                                4. Compute HMAC-SHA256(secret, raw_body)
                                                5. Compare with X-OpenFX-Signature
                                                6. Match? Process. No match? Reject.

Step-by-step verification

Step 1: Extract the signature header

Read the X-OpenFX-Signature header from the incoming request. This is a hex-encoded string.

Step 2: Get the raw request body

Read the raw request body as bytes. Do not parse it to JSON first, because JSON serialization may change whitespace or field ordering, which would invalidate the signature.

Step 3: Compute the expected signature

Use your stored signing secret and the raw body to compute an HMAC-SHA256 digest. Hex-encode the result.

Step 4: Compare signatures

Compare your computed signature against the X-OpenFX-Signature header value. Use a constant-time comparison function to prevent timing attacks.

Step 5: Accept or reject

If the signatures match, the webhook is authentic — process it. If they do not match, return a 401 or 403 status code and do not process the payload.

Code examples

import hashlib
import hmac
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = "whsec_a1b2c3d4e5f6..."  # From subscription creation

@app.route("/webhooks/openfx", methods=["POST"])
def handle_webhook():
    # Step 1: Extract signature header
    received_signature = request.headers.get("X-OpenFX-Signature")
    if not received_signature:
        abort(401, "Missing X-OpenFX-Signature header")

    # Step 2: Get raw request body
    raw_body = request.get_data()

    # Step 3: Compute expected signature
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    # Step 4: Constant-time comparison
    if not hmac.compare_digest(expected_signature, received_signature):
        abort(401, "Invalid signature")

    # Step 5: Process the verified webhook
    event = request.get_json()
    event_type = event["type"]
    resource_id = event["data"]["resourceId"]

    print(f"Verified event: {event_type} for {resource_id}")

    # Handle the event...
    handle_event(event)

    return "", 200


def handle_event(event):
    match event["type"]:
        case "payment.completed":
            print(f"Payment {event['data']['resourceId']} completed")
        case "payment.failed":
            print(f"Payment {event['data']['resourceId']} failed")
        case "customer.kyb_status_changed":
            print(f"Customer {event['data']['resourceId']} KYB updated")
        case _:
            print(f"Unhandled event type: {event['type']}")

Replay protection

Every webhook delivery includes an X-OpenFX-Timestamp header containing the Unix timestamp (seconds) when the delivery was sent. To prevent replay attacks, reject any delivery where the timestamp is more than 300 seconds (5 minutes) from the current time.
import time

received_timestamp = int(request.headers.get("X-OpenFX-Timestamp", "0"))
current_time = int(time.time())

if abs(current_time - received_timestamp) > 300:
    abort(401, "Timestamp too old — possible replay attack")
For maximum security, include the timestamp in your signature verification by verifying both X-OpenFX-Signature and X-OpenFX-Timestamp together. The server-side signature computation includes the timestamp, so a tampered timestamp will cause signature verification to fail.

Common pitfalls

Parsing the body before verifying

If your web framework automatically parses the JSON body and you re-serialize it for verification, the result may differ from the original payload due to whitespace or field ordering changes. Always verify against the raw bytes of the request body.
In Express.js, use express.raw({ type: "application/json" }) on your webhook route instead of express.json(). In Flask, use request.get_data() instead of request.get_json() for the verification step. In Go, use io.ReadAll(r.Body).

Not using constant-time comparison

A naive string comparison (==) can leak timing information that allows an attacker to determine the correct signature byte by byte. Always use a constant-time comparison function:
LanguageFunction
Pythonhmac.compare_digest(a, b)
JavaScriptcrypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
Gohmac.Equal(a, b)
RubyRack::Utils.secure_compare(a, b)
JavaMessageDigest.isEqual(a, b)

Mismatched secret

If verification consistently fails, check that:
  1. You are using the signing secret from the correct subscription (you may have multiple).
  2. The secret has not been rotated. If it was, use the new secret. During the 24-hour grace period after rotation, try both the old and new secrets.
  3. The secret is stored correctly — no trailing whitespace or encoding issues.

What to do when verification fails

If the signature does not match:
  1. Return a 401 or 403 status code. Do not process the payload.
  2. Log the failure with the received signature, your computed signature, and the request headers for debugging.
  3. Do not expose details in the HTTP response body. A simple “Unauthorized” is sufficient.
  4. Alert on repeated failures. Consistent verification failures may indicate an attack, a misconfigured secret, or a secret rotation you missed.
Never process a webhook payload that fails signature verification. Treat it as potentially malicious and reject it immediately.

Next steps