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

# Webhooks setup

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

Webhooks turn the deposit and withdrawal 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">
    OpenFX delivers five event types across two resource families. Every event type name follows a dotted, lowercase `{resource}.{action}` convention, so you can match a whole family by prefix (e.g. `withdrawal.*`):

    **Deposit events**

    * `deposit.completed` — a fiat or stablecoin deposit has been credited to your balance.
    * `deposit.failed` — a deposit attempt has reached a terminal failure state.

    **Withdrawal events**

    * `withdrawal.processing` — a withdrawal has been accepted and is moving through the payment rail.
    * `withdrawal.completed` — a withdrawal has settled successfully.
    * `withdrawal.failed` — a withdrawal has reached a terminal failure state.

    Each event delivers a single resource object in `data` — the Deposit or Withdrawal in its current state. Subscribe to whichever events your integration needs; if in doubt, subscribe to all five.
  </Step>

  <Step title="Copy the signing secret">
    The dashboard generates a per-org signing secret on first registration. Store it as `OPENFX_WEBHOOK_SECRET` 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 secrets are different.** A handler that uses the Live
  secret to validate Sandbox events will reject every one. Wire the secret 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={null}
import crypto from "node:crypto";
import express from "express";

const app = express();

// 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");

    // Parse the timestamp + HMAC from the header: "t=<unix>,v1=<hex>"
    const parts = Object.fromEntries(
      (sigHeader ?? "").split(",").map((p) => p.split("=")),
    );
    const timestamp = parts["t"];
    const receivedHmac = parts["v1"];

    if (!timestamp || !receivedHmac) {
      return res.status(401).end();
    }

    // Replay window: reject events timestamped more than 5 minutes ago.
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(401).end();
    }

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

    const a = Buffer.from(receivedHmac);
    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 dotted action, e.g. "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={null}
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)

@app.post('/webhooks/openfx')
def openfx_webhook():
    sig_header = request.headers.get('x-openfx-signature', '')

    # Parse "t=<unix>,v1=<hex>"
    parts = dict(p.split('=', 1) for p in sig_header.split(',') if '=' in p)
    timestamp = parts.get('t')
    received_hmac = parts.get('v1')

    if not timestamp or not received_hmac:
        return '', 401

    # Replay window: reject events timestamped more than 5 minutes ago.
    if abs(time.time() - float(timestamp)) > 300:
        return '', 401

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

    if not hmac.compare_digest(received_hmac, 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 and replay-attack prevention.

## 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={null}
// 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) to select the Sandbox environment.
2. Trigger a Sandbox deposit (your CS rep can simulate one) or a Sandbox withdrawal (initiate a small one).
3. Verify your handler receives the signed event and returns `2xx`.
4. Check the dashboard's delivery log for the event.

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={null}
{
  "id": "evt_7m4VsfRw4pGrS76WYj5tnx",
  "type": "deposits",
  "eventType": "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: `"deposits"` or `"withdrawals"`. Use it to route by resource family.                                                                                                                |
| `eventType` | string                              | Dotted, lowercase `{resource}.{action}` describing what happened: `deposit.completed`, `deposit.failed`, `withdrawal.processing`, `withdrawal.completed`, `withdrawal.failed`. Match by prefix (e.g. `withdrawal.*`). |
| `createdAt` | string (RFC 3339, ms, UTC)          | When OpenFX created the event, e.g. `2026-04-28T08:32:50.618Z`.                                                                                                                                                       |
| `data`      | object                              | The single resource this event concerns — a Deposit for `deposit.*`, a Withdrawal for `withdrawal.*` — in the v3 API shape.                                                                                           |

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.

The field-level reference is in:

* [Deposit webhooks](/v3/webhooks/deposits)
* [Withdrawal webhooks](/v3/webhooks/withdrawals)

## 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 [Deposit lifecycle](/v3/deposit-lifecycle) 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/withdrawal-lifecycle#polling-pattern-with-adaptive-backoff) on the [Withdrawal lifecycle](/v3/withdrawal-lifecycle) 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 checking the replay window.** Verify the `t=` timestamp in the signature header and reject events older than 5 minutes to prevent replay attacks.
* **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 secrets.** Wire the secret from environment config, not a constant.

## What's next

<CardGroup cols={2}>
  <Card title="Webhook authentication" icon="lock" href="/v3/webhooks/authentication">
    Signature scheme + verification code samples.
  </Card>

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

  <Card title="Verified accounts" icon="shield-check" href="/v3/setup/verified-accounts">
    Pre-configure withdrawal accounts the webhooks will report on.
  </Card>
</CardGroup>
