Docs

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

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:

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

What arrives

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.

EventWhendata
order.filledAn 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.rejectedThe broker refused an orderThe order, with previousState
order.unresolvedAn order was sent and no answer came back; reconciliation takes overThe order, with previousState
order.resolvedAn unresolved order's outcome was found (an order.filled follows if it filled)The order, with previousState
order.cancelledA pending order was withdrawnThe order, with previousState
position.openedA position appeared on the accountThe position
position.closedA position is gone from the account, whatever closed itThe position
account.state_changedThe 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.settledA multi-account order finished: settled, cancelled or abandonedThe multi-account order with its legs
copy.executedA copy reached a follower's brokerThe execution, with the signal it copied and the follower's orderId
copy.skippedA copy was not sent: too late, price moved, follower offline, a limit…The execution, with reason and detail
copy.failedA copy was sent and refused, or its result is unknown (reason unresolved)The execution, with reason and detail
copy.subscription_stoppedA 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_correctedThe drift check put a follower right (closed, reduced, moved stops, or opened)The execution, with the drift signal and its correction
copy.master_disconnectedA strategy's master stopped answeringThe strategy, with disconnectedAt
webhook.testYou 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

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:

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

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

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.
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
  }
}
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:

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

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 with the event list, or replay them (POST /v1/webhooks/{id}/replay).

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

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

PlanEvents kept (/v1/events, replay)Delivery logs
Production, Starter, Trial7 days30 days
Business, Scale30 days30 days
Enterprise, agreements90 days30 days
Free— (no webhooks)—

On this page