# Webhooks

URL: https://docs.fxapis.com/guides/webhooks

> Get a signed HTTPS POST when an order fills, a position opens or closes, an account changes state, or a multi-account order finishes.



Instead of polling, give fxapis a URL and it tells you when something happens.
Each event is a signed `POST`, sent within a few seconds and retried for about
two days if your server is down.

Webhooks are on every paid plan. Manage them in the console under **Webhooks**,
or with the API (`/v1/webhooks`, scope `webhooks:write`). Every event is also
kept in your **event history** — 7 days on Production (and Starter), 30 on
Business (and Scale), 90 on Enterprise — readable with `GET /v1/events`.

## Add an endpoint [#add-an-endpoint]

```bash
curl https://api.fxapis.com/v1/webhooks \
  -H "Authorization: Bearer $FXAPIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/fxapis/webhooks", "events": ["order.filled", "position.closed"]}'
```

The response carries `secret` (`whsec_…`). **It is shown once** — store it with
your other secrets. Leave out `events` to receive every event. Up to 10 endpoints
per workspace; each URL must be HTTPS on port 443 and reachable from the internet.

Send yourself a test event to check the wiring:

```bash
curl -X POST https://api.fxapis.com/v1/webhooks/$ENDPOINT_ID/test \
  -H "Authorization: Bearer $FXAPIS_API_KEY"
```

## What arrives [#what-arrives]

```http
POST /fxapis/webhooks HTTP/1.1
Content-Type: application/json
fxapis-event-id: 5b0e7c1a-2f7d-4c1e-9a53-0e6f3b1c9a10
fxapis-signature: t=1791400000,v1=5f2b…

{
  "id": "5b0e7c1a-2f7d-4c1e-9a53-0e6f3b1c9a10",
  "type": "order.filled",
  "createdAt": "2026-10-08T12:00:00.000Z",
  "data": { "id": "…", "accountId": "…", "symbol": "EURUSD", "state": "filled", "previousState": "sending", "…": "…" }
}
```

`data` is the resource exactly as the REST API returns it — the order from
`GET /v1/orders/{id}`, the position from the positions list, the account from
`GET /v1/accounts/{id}`, the multi-account order from
`GET /v1/execution-waves/{id}` — read once, when the event is sent out, normally
within two seconds of the change. A retry or a redelivery sends that same
snapshot, so read the resource again if you need its state now.

| Event                       | When                                                                                                                                                                                                                                                                         | `data`                                                                                                                                                                                                                                          |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `order.filled`              | An order filled, fully or partly — from the broker's reply, found later by reconciliation, or a pending order that triggered (`previousState` `working`)                                                                                                                     | The order, with `previousState`. An opening order's `brokerPositionId` is there when the fill named it; one linked later sends no webhook, so read the order (or match the position's ticket to its `brokerOrderId`)                            |
| `order.rejected`            | The broker refused an order                                                                                                                                                                                                                                                  | The order, with `previousState`                                                                                                                                                                                                                 |
| `order.unresolved`          | An order was sent and no answer came back; reconciliation takes over                                                                                                                                                                                                         | The order, with `previousState`                                                                                                                                                                                                                 |
| `order.resolved`            | An unresolved order's outcome was found (an `order.filled` follows if it filled)                                                                                                                                                                                             | The order, with `previousState`                                                                                                                                                                                                                 |
| `order.cancelled`           | A pending order was withdrawn                                                                                                                                                                                                                                                | The order, with `previousState`                                                                                                                                                                                                                 |
| `position.opened`           | A position appeared on the account                                                                                                                                                                                                                                           | The position                                                                                                                                                                                                                                    |
| `position.closed`           | A position is gone from the account, whatever closed it                                                                                                                                                                                                                      | The position                                                                                                                                                                                                                                    |
| `account.state_changed`     | The account became `ready`, `offline` or `standby`, or needs you (`invalid_credentials`, `needs_2fa`, `needs_certificate`, `trading_disabled`, `error`)                                                                                                                      | The account, with `previousState` — without balance or equity, which change without a state change; read `GET /v1/accounts/{id}/figures`. `marginMode` here is `netting`, `hedging` or, usually for now, `null`; `GET /v1/accounts/{id}` has it |
| `wave.settled`              | A multi-account order finished: `settled`, `cancelled` or `abandoned`                                                                                                                                                                                                        | The multi-account order with its legs                                                                                                                                                                                                           |
| `copy.executed`             | A copy reached a follower's broker                                                                                                                                                                                                                                           | The execution, with the signal it copied and the follower's `orderId`                                                                                                                                                                           |
| `copy.skipped`              | A copy was not sent: too late, price moved, follower offline, a limit…                                                                                                                                                                                                       | The execution, with `reason` and `detail`                                                                                                                                                                                                       |
| `copy.failed`               | A copy was sent and refused, or its result is unknown (`reason` `unresolved`)                                                                                                                                                                                                | The execution, with `reason` and `detail`                                                                                                                                                                                                       |
| `copy.subscription_stopped` | A subscription stopped: a risk limit (`risk_limit:<period>:<metric>`, with `:unverified` when the deposits and withdrawals could not be confirmed), a netting follower, its creator losing access (`creator_revoked`), or its deletion with its positions closed (`deleted`) | The subscription, with `stopReason`, `stopDetail` (in words) and `previousStatus`                                                                                                                                                               |
| `copy.drift_corrected`      | The drift check put a follower right (closed, reduced, moved stops, or opened)                                                                                                                                                                                               | The execution, with the drift signal and its `correction`                                                                                                                                                                                       |
| `copy.master_disconnected`  | A strategy's master stopped answering                                                                                                                                                                                                                                        | The strategy, with `disconnectedAt`                                                                                                                                                                                                             |
| `webhook.test`              | You asked for a test                                                                                                                                                                                                                                                         | `{ endpointId, message }`                                                                                                                                                                                                                       |

Positions come from the account's position sync, which runs about every 15
seconds while the account is online — so `position.opened` and
`position.closed` arrive up to about 15 seconds after the broker changed them.
A position that was already open when the account connected is reported as
opened the first time it is seen.

## Verify the signature [#verify-the-signature]

Check every delivery before trusting it. `fxapis-signature` is
`t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body" with your secret>`. Use the
raw body — the bytes as they arrived, before JSON parsing — and reject anything
more than 5 minutes old.

**Node / TypeScript** (`npm install fxapis` — version 0.1.4 or newer). `verifyWebhook`
uses Web Crypto, so it also runs on Deno, Bun and edge runtimes:

```ts
import { verifyWebhook } from "fxapis";

app.post("/fxapis/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook(req.body.toString("utf8"), req.header("fxapis-signature"), process.env.FXAPIS_WEBHOOK_SECRET!);
  } catch {
    return res.sendStatus(400);
  }
  if (await alreadyHandled(event.id)) return res.sendStatus(200);
  await handle(event);
  res.sendStatus(200);
});
```

**Python** (`pip install fxapis` — version 0.1.3 or newer):

```python
from fxapis import WebhookVerificationError, webhooks

@app.post("/fxapis/webhooks")
def receive(request):
    try:
        event = webhooks.verify(request.body, request.headers.get("fxapis-signature"), FXAPIS_WEBHOOK_SECRET)
    except WebhookVerificationError:
        return Response(status=400)
    if already_handled(event["id"]):
        return Response(status=200)
    handle(event)
    return Response(status=200)
```

**Any language:** split the header on `,`; take `t` and every `v1`; compute
`HMAC-SHA256(secret, t + "." + rawBody)` as lowercase hex; accept if it equals
any `v1` (compare in constant time) and `t` is within 300 seconds of now.

## Answer quickly, and expect repeats [#answer-quickly-and-expect-repeats]

* Answer `2xx` within 10 seconds. Do slow work after answering. Redirects are not
  followed — a `3xx` counts as a failure.
* Delivery is **at least once**: the same event can arrive twice (a retry after a
  timeout, or a redelivery you asked for). Deduplicate on `id`.
* Events for different resources can arrive out of order. Use `createdAt` and the
  resource's own state, not arrival order. Two events from one change — an
  `order.resolved` and the `order.filled` after it — carry the same `createdAt`.
* Busy? Answer `429` or `503` with `Retry-After` and the next attempt waits at
  least that long (up to 24 hours).

## Catch up after downtime [#catch-up-after-downtime]

Webhooks are the fast path; the event list is the safety net. Every event is
kept whether or not your endpoint was listening, and `GET /v1/events?after=<id>`
returns everything after the event with that id, oldest first — the way
OANDA's `sinceid` and Alpaca's `since_ulid` work. Each item is **exactly the
webhook body** (`id`, `type`, `createdAt`, `data`), so one handler processes both.

An event appears in the list a few seconds after it happens, and only once every
earlier event has, so paging forward never skips one that is still being prepared.
(The one exception: an event stuck more than 15 minutes before it could be sent
out — rare, and we are alerted — stops holding the list back.)

The loop:

1. Keep one stored id: your **anchor**. Only ever take it from the **last item of
   a list page you have processed** — never from a webhook. Webhooks do not
   arrive in list order (sends run in parallel, retries are spread out, and two
   events from one change share a `createdAt`), so a webhook's id can be newer
   than an event that is still retrying; resuming `after=` it would skip that one.
2. On startup, page through `/v1/events?after=<anchor>` until a page comes back
   empty, moving the anchor to each page's last item. Run the same loop
   periodically if you like.
3. Then (or every so often) pick up anything your endpoint was given up on: read
   `/v1/events?delivery_status=failed`, or run a replay (below), which by default
   sends only what the endpoint missed.
4. Handle webhooks as they arrive, without touching the anchor. Deduplicate on
   `id` everywhere — the same event can arrive by every route.

```ts
import { Fxapis } from "fxapis";

const fx = new Fxapis(process.env.FXAPIS_API_KEY!); // a key with history:read

async function catchUp(anchor: { get(): Promise<string | null>; set(id: string): Promise<void> }) {
  let after = await anchor.get();
  if (!after) {
    // First run: nothing to catch up on. Start from the newest event.
    const [newest] = await fx.getEvents({ query: { limit: 1 } });
    if (newest) await anchor.set(newest.id);
    return;
  }
  for (;;) {
    const events = await fx.getEvents({ query: { after, limit: 200 } });
    if (events.length === 0) break;
    for (const event of events) await handle(event); // the same function your webhook route calls; it ignores ids it has seen
    after = events[events.length - 1]!.id;
    await anchor.set(after); // only from the list, once the page is done
  }
}
```

```python
from fxapis import Fxapis

fx = Fxapis()  # FXAPIS_API_KEY, with history:read


def catch_up(anchor) -> None:
    after = anchor.get()
    if after is None:
        newest = fx.events.list(limit=1).data
        if newest:
            anchor.set(newest[0]["id"])
        return
    while True:
        events = fx.events.list(after=after, limit=200).data  # oldest first
        if not events:
            break
        for event in events:
            handle(event)  # the same function your webhook route calls; it ignores ids it has seen
        after = events[-1]["id"]
        anchor.set(after)  # only from the list, once the page is done
```

If your anchor has aged out of your event history, the API answers
`400 EVENT_NOT_IN_HISTORY`. Resume with `since=<a time>` instead, and
reconcile anything older from the REST API (orders, positions).

Narrow the list with `type` (repeat it, or separate with commas), `account_id`
(including multi-account orders with a leg on that account), `since`/`until`, and
`delivery_status=failed` — events one of your endpoints has not received, given up
on or still retrying. Reading events needs the `history:read` scope.

**No code? Replay.** In the console, open the endpoint and choose **Replay
events**, or:

```bash
curl -X POST https://api.fxapis.com/v1/webhooks/$ENDPOINT_ID/replay \
  -H "Authorization: Bearer $FXAPIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"since": "2026-10-13T00:00:00Z"}'
```

By default this sends only what the endpoint **missed** in that window:
deliveries it gave up on, and events recorded while it was switched off. It is
safe to repeat. Up to 10,000 events per call (`nextSince` says where to
continue), within your event history. `"include": "all"` also re-sends events it
already received — **if your receiver copies trades or acts on orders, make sure
it ignores an `id` it has already handled before you use it.**

## Retries and switching off [#retries-and-switching-off]

A delivery that does not get a `2xx` is retried after about 1 minute, 5 minutes,
30 minutes, 2, 6, 12 and 24 hours — eight attempts over about 44.6 hours before
jitter — then given up. Each wait is spread by up to 20%, so endpoints that
failed together do not all retry together; the last wait alone can run from
about 19.2 to 28.8 hours. If your answer carries `Retry-After` (seconds, or an
HTTP date), the next attempt waits at least that long, up to 24 hours. Each
endpoint is retried on its own, and gets at most five deliveries at a time.

After **20 failed deliveries in a row**, once the failures have gone on for at
least 15 minutes, the endpoint is switched off and the workspace owner gets an
email. A shorter outage — a deploy, a restart — never switches it off, however
many events fail during it. Answering **`410 Gone`** switches it off at once:
use it when you retire a URL.

While an endpoint is off (switched off by us, or paused by you), deliveries
that were already waiting for it are **kept**, and resume on their schedule
when you turn it back on (console, or `POST /v1/webhooks/{id}/enable`). Events
recorded while it was off are not sent automatically —
[catch up](#catch-up-after-downtime) with the event list, or replay them
(`POST /v1/webhooks/{id}/replay`).

## Rotate the secret [#rotate-the-secret]

`POST /v1/webhooks/{id}/rotate` returns a new secret, once. For the next 24 hours
every delivery is signed with **both** secrets — two `v1=` values — so you can
deploy the new secret and then retire the old one without missing an event.

## Delivery logs [#delivery-logs]

Every attempt is kept for **30 days on every plan** — even after the event
itself has aged out of your event history:

* **The request as sent:** URL, headers (signature included) and the exact body.
* **Your endpoint's answer:** status, headers and the first 4 KB of the body.
* **How long it took**, and the error if there was no answer.

In the console, open an endpoint and click a delivery. With the API:
`GET /v1/webhooks/{id}/deliveries?status=failed&eventType=order.filled` (paged by
`page.nextCursor`) and `GET /v1/webhooks/{id}/deliveries/{deliveryId}`.

If an endpoint shows a **problem** (`SIGNING_SECRET_UNAVAILABLE`), we can't
sign its deliveries; they are held, not lost or counted as failures. Rotate its
secret to resume them.

## Event history by plan [#event-history-by-plan]

| Plan                                                       | Events kept (`/v1/events`, replay) | Delivery logs |
| ---------------------------------------------------------- | ---------------------------------- | ------------- |
| Production (and a trial of it), Starter, the generic Trial | 7 days                             | 30 days       |
| Business (and a trial of it), Scale                        | 30 days                            | 30 days       |
| Enterprise, agreements                                     | 90 days                            | 30 days       |
| Free                                                       | — (no webhooks)                    | —             |
