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.
| 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
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
2xxwithin 10 seconds. Do slow work after answering. Redirects are not followed — a3xxcounts 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
createdAtand the resource's own state, not arrival order. Two events from one change — anorder.resolvedand theorder.filledafter it — carry the samecreatedAt. - Busy? Answer
429or503withRetry-Afterand 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:
- 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; resumingafter=it would skip that one. - 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. - 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. - Handle webhooks as they arrive, without touching the anchor. Deduplicate on
ideverywhere — 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 doneIf 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
| Plan | Events kept (/v1/events, replay) | Delivery logs |
|---|---|---|
| Production, Starter, Trial | 7 days | 30 days |
| Business, Scale | 30 days | 30 days |
| Enterprise, agreements | 90 days | 30 days |
| Free | — (no webhooks) | — |
Idempotent MT5 orders: never double-fill on retry
Why retrying a MetaTrader 5 order can open a second position, and how the fxapis Idempotency-Key prevents it: 24-hour keys, replays, in-flight and reuse conflicts, the unknown outcome, and key patterns for signals, copiers and queues.
TradingView alerts to MT5 — no EA, no VPS
Turn TradingView alerts into MetaTrader 5 orders with a secret webhook URL: create a hook in the console or the API, set up the TradingView alert step by step, message formats and placeholders for indicators and strategies, closing, idempotency, the IP allowlist, troubleshooting and limits.