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

## 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 when the event is sent, normally within
two seconds of the change.

| Event                   | When                                                                                                                                                    | `data`                                |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `order.filled`          | An order filled, fully or partly — from the broker's reply or found later by reconciliation                                                             | The order, with `previousState`       |
| `order.rejected`        | The broker refused an order                                                                                                                             | The order                             |
| `order.unresolved`      | An order was sent and no answer came back; reconciliation takes over                                                                                    | The order                             |
| `order.resolved`        | An unresolved order's outcome was found (an `order.filled` follows if it filled)                                                                        | The order                             |
| `order.cancelled`       | A pending order was withdrawn                                                                                                                           | The order                             |
| `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`     |
| `wave.settled`          | A multi-account order finished: `settled`, `cancelled` or `abandoned`                                                                                   | The multi-account order with its legs |
| `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`):

```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`):

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

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

A delivery that does not get a `2xx` is retried after 1 minute, 5 minutes,
30 minutes, 2, 6, 12 and 24 hours — eight attempts over about 45 hours — then
given up. Each endpoint retries on its own: one failing endpoint never delays
another.

After **20 failed deliveries in a row** the endpoint is switched off and the
workspace owner gets an email. Fix it and resume it in the console (or
`POST /v1/webhooks/{id}/enable`). Events already queued to it before it was
switched off can be redelivered from its delivery log
(`POST /v1/webhooks/{id}/deliveries/{eventId}/redeliver`); events recorded
while it was off never reached that log and can't be redelivered that way.
Events and delivery logs are kept for 30 days.

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