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

# Webhooks setup

> Register an HTTPS endpoint, store the signing secret, verify every event, and stop polling.

Webhooks turn OpenFX resource lifecycles from a polling problem into a push-driven one. OpenFX delivers signed HTTP POSTs to an HTTPS endpoint you control the moment an event reaches a meaningful state. This page walks you from "no endpoint" to "events are verified and processed".

If you only need the signature-verification reference, jump to [Webhook authentication](/v3/webhooks/authentication). This page is the operational setup around that.

## Prerequisites

* A publicly reachable **HTTPS** endpoint (no plain HTTP: signatures protect integrity, TLS protects in-transit confidentiality)
* An admin user in the [OpenFX dashboard](https://app.openfx.com)
* A handler that can hold a small amount of state (deduplication of replayed events)

## Step 1: Register your endpoint

<Steps>
  <Step title="Open the dashboard">
    Sign in to [app.openfx.com/webhooks](https://app.openfx.com/webhooks), your organization's **Webhooks** settings.
  </Step>

  <Step title="Add a webhook URL">
    Enter your HTTPS URL. The dashboard supports one webhook URL per environment (Live and Sandbox have separate registrations; see [Environments](/v3/environments)).
  </Step>

  <Step title="Subscribe to event types">
    Every event type name follows a dot-namespaced, lowercase `{product}.{resource}.{action}` convention (`fx.` for FX), so you can route on a whole family by prefix (e.g. `fx.withdrawal.*`) or on individual event types (e.g. <span class="ofx-enum">fx.deposit.completed</span>). See [Deposit webhooks](/v3/webhooks/deposits/deposit-completed), [Withdrawal webhooks](/v3/webhooks/withdrawals/withdrawal-completed), and [Trade webhooks](/v3/webhooks/trades/trade-ready-to-settle) for the exact event types each resource emits.

    Subscribe to whichever events your integration needs; if in doubt, subscribe to everything.
  </Step>

  <Step title="Copy the signing key">
    The dashboard generates a per-org signing key on first registration. Store it as `OPENFX_WEBHOOK_SIGNING_KEY` in your secrets manager. **Treat it like a database password**: anyone with it can mint events that pass signature verification.
  </Step>
</Steps>

<Warning>
  **Sandbox and Live signing keys are different.** A handler that uses the Live
  signing key to validate Sandbox events will reject every one. Wire the key
  from environment-aware config, not a hard-coded constant.
</Warning>

## Step 2: Build the handler

Minimum viable handler: read the raw body, verify the signature, deduplicate by event `id`, dispatch.

```javascript Node / Express theme={"theme":{"light":"poimandres","dark":"poimandres"}}
import crypto from "node:crypto";
import express from "express";

const app = express();
const TOLERANCE_SECONDS = 300;

// IMPORTANT: read the raw body. JSON re-serialization breaks signatures.
app.post(
  "/webhooks/openfx",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sigHeader = req.header("x-openfx-signature");
    const fields = Object.fromEntries(
      (sigHeader || "")
        .split(",")
        .map((part) => part.split("="))
        .filter(([key, value]) => key && value),
    );
    if (!fields.t || !fields.v1) {
      return res.status(401).end();
    }

    const timestamp = Number(fields.t);
    const now = Math.floor(Date.now() / 1000);
    if (
      !Number.isFinite(timestamp) ||
      Math.abs(now - timestamp) > TOLERANCE_SECONDS
    ) {
      return res.status(401).end();
    }

    const expected = crypto
      .createHmac("sha256", process.env.OPENFX_WEBHOOK_SIGNING_KEY)
      .update(`${timestamp}.${req.body}`)
      .digest("hex");

    const a = Buffer.from(fields.v1);
    const b = Buffer.from(expected);
    // timingSafeEqual throws on length mismatch; guard first.
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString("utf8"));

    // Idempotent processing: events can be redelivered. Dedupe by event id.
    if (await store.alreadyProcessed(event.id)) {
      return res.status(200).end();
    }

    // event.type is the resource noun, e.g. "deposits" or "withdrawals"
    // event.eventType is the product-prefixed action, e.g. "fx.deposit.completed"
    // event.data is the single resource object.
    await dispatch(event);
    await store.markProcessed(event.id);

    res.status(200).end();
  },
);
```

```python Python / Flask theme={"theme":{"light":"poimandres","dark":"poimandres"}}
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
TOLERANCE_SECONDS = 300

@app.post('/webhooks/openfx')
def openfx_webhook():
    sig_header = request.headers.get('x-openfx-signature', '')
    fields = {}
    for part in sig_header.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 '', 401

    try:
        timestamp = int(fields['t'])
    except ValueError:
        return '', 401
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return '', 401

    raw_body = request.get_data()
    signed_payload = f"{timestamp}.".encode('utf-8') + raw_body
    expected = hmac.new(
        os.environ['OPENFX_WEBHOOK_SIGNING_KEY'].encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(fields['v1'], expected):
        return '', 401

    event = request.get_json(force=True)

    # Dedupe by event id
    if store.already_processed(event['id']):
        return '', 200

    dispatch(event)
    store.mark_processed(event['id'])
    return '', 200
```

The signature-verification half of this is covered in full in [Webhook authentication](/v3/webhooks/authentication), including additional language samples.

## Step 3: Acknowledge fast, process async

Return `2xx` within a few seconds. Long-running processing (database writes, downstream API calls, sending the user an email) belongs on a queue:

```javascript theme={"theme":{"light":"poimandres","dark":"poimandres"}}
// Inside the handler:
await queue.enqueue("process-openfx-event", event);
res.status(200).end();
```

OpenFX retries deliveries that don't ack with `2xx`. The combination of "ack fast" + "process via queue" + "deduplicate by event `id`" makes retries safe.

## Step 4: Test in Sandbox

The fastest path to a working integration is to fire events from Sandbox before going to Live:

1. Use your Sandbox API key (`sandbox_`-prefixed), which is bound to the Sandbox environment. If you send `X-App-Mode`, set it to <span class="ofx-enum">SANDBOX</span>; omitting it also selects Sandbox.
2. Trigger a Sandbox deposit or withdrawal yourself from Sandbox mode in the dashboard. Sandbox doesn't move real money, so there's no amount to worry about.
3. Verify your handler receives the signed event and returns `2xx`.
4. Check the dashboard's delivery log for the event.

Trade webhooks (<span class="ofx-enum">fx.trade.ready\_to\_settle</span>, <span class="ofx-enum">fx.trade.settled</span>, <span class="ofx-enum">fx.trade.reversed</span>) are delivered only to Live webhooks. Sandbox trades never emit them, so you can't exercise a trade handler in Sandbox.

See [Environments](/v3/environments) for the Sandbox-vs-Live split.

## Event envelope shape

Every webhook delivery uses these top-level routing fields. This abbreviated shape collapses `data` to an empty object and omits the required Deposit or Withdrawal fields; use the complete, schema-checked event examples on the payload pages when implementing a handler.

```json theme={"theme":{"light":"poimandres","dark":"poimandres"}}
{
  "id": "evt_7m4VsfRw4pGrS76WYj5tnx",
  "type": "deposits",
  "eventType": "fx.deposit.completed",
  "createdAt": "2026-04-28T08:32:50.618Z",
  "data": {}
}
```

| Field | Type | Notes |
| - | - | - |
| `id` | string (readable ID, `evt_` prefix) | Unique per event. Use for server-side deduplication: dedupe on `id` before processing. |
| `type` | string | Resource noun the event concerns: <span class="ofx-enum">deposits</span>, <span class="ofx-enum">withdrawals</span>, or <span class="ofx-enum">trades</span>. Use it to route by resource family. |
| `eventType` | string | Dot-namespaced, product-prefixed action name: <span class="ofx-enum">fx.deposit.completed</span>, <span class="ofx-enum">fx.withdrawal.completed</span>, or <span class="ofx-enum">fx.trade.ready\_to\_settle</span>/<span class="ofx-enum">fx.trade.settled</span>/<span class="ofx-enum">fx.trade.reversed</span>. Match by prefix (e.g. `fx.withdrawal.*`) if you want to route on the product tag alone. |
| `createdAt` | string (RFC 3339, ms, UTC) | When OpenFX built and dispatched this delivery, e.g. `2026-04-28T08:32:50.618Z`. Not when the underlying deposit or withdrawal happened. |
| `data` | object | The single resource this event concerns, close to but not identical to its v3 REST shape. See the payload pages linked below for the exact fields. |

Field names are `camelCase`; monetary amounts are strings (e.g. `"1000.00"`); timestamps are RFC 3339 with millisecond precision and a trailing `Z`. This matches the v3 REST API conventions exactly, deposit, withdrawal, and trade settlement events alike.

The field-level reference is in:

* [Deposit webhooks](/v3/webhooks/deposits/deposit-completed)
* [Withdrawal webhooks](/v3/webhooks/withdrawals/withdrawal-completed)
* [Trade webhooks](/v3/webhooks/trades/trade-ready-to-settle)
* [Payout webhooks](/v3/webhooks/payments/payouts-webhooks/payout-created)

## Polling fallback

Webhooks are the primary delivery channel, but if your handler is briefly unreachable, OpenFX retries. As a defense-in-depth, periodically reconcile against the read endpoints:

* [`GET /v3/fx/deposits`](/v3/api-reference/trade-settlement/list-deposits) for missed deposit events; pair with [Deposits](/v3/deposits) for the state model and reconcilable fields
* [`GET /v3/fx/withdrawals`](/v3/api-reference/trade-settlement/list-withdrawals) for missed withdrawal events; use the [adaptive-backoff pattern](/v3/withdrawals#fallback-polling-pattern-with-adaptive-backoff) on the [Withdrawals](/v3/withdrawals) page so a slow rail doesn't burn your rate-limit budget

Use [cursor pagination](/v3/pagination) and walk forward from the last ID you've processed. Don't poll continuously: a slow reconciliation sweep every few minutes is enough.

## Common mistakes

* **Parsing the body before verifying.** JSON re-serialization changes whitespace and key order, which changes the HMAC digest. Always verify against the **raw** request body.
* **String comparison on signatures.** Use a constant-time comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`). Standard `===` leaks timing.
* **Not deduplicating by event `id`.** A handler that 500s on the first delivery and 200s on the redelivery will receive the same event twice. Store `event.id` server-side and short-circuit duplicates. **Retain event IDs for at least 48 hours.** OpenFX retries failed deliveries on a backoff curve within that window; older IDs can be safely purged.
* **Long-running work before acking.** A 30-second database write turns into a webhook timeout, which turns into a retry, which lands you with three copies of the same work in flight. Ack first, process async.
* **Mixing Sandbox and Live signing keys.** Wire the key from environment config, not a constant.

## What's next

<CardGroup cols={2}>
  <Card title="Webhook authentication" icon="https://public-assets.openfx.com/docs/icons/webhook-authentication.svg" href="/v3/webhooks/authentication">
    Signature scheme + verification code samples.
  </Card>

  <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 withdrawal completion.
  </Card>

  <Card title="Trade webhooks" icon="https://public-assets.openfx.com/docs/icons/webhook-trades.svg" href="/v3/webhooks/trades/trade-ready-to-settle">
    Event shape for ready-to-settle, settled, and reversed trades.
  </Card>

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

  <Card title="Verified accounts" icon="https://public-assets.openfx.com/docs/icons/verified-accounts.svg" href="/v3/verified-accounts">
    Pre-configure withdrawal accounts the webhooks will report on.
  </Card>
</CardGroup>
