# Signals and click-to-trade

URL: https://docs.fxapis.com/signals

> A complete integration for apps whose members approve each trade: connecting their accounts, instant execution on approval, showing their trades, and closing.



This guide is for products where a signal goes out and **each member decides, with a click, whether to take the trade** — and you want that click to execute at once, on the member's own MetaTrader 5 account, without keeping every account online all day.

It covers the whole integration, end to end. Every call is a normal API call with your key, made from **your backend** — never from your app or the browser.

```
member connects MT5 account ──► POST /v1/accounts            (once)
signal published            ──► nothing yet
member opens the signal     ──► POST /v1/accounts/prepare    (their account comes online)
member approves             ──► POST /v1/accounts/{id}/orders/market
member views their trades   ──► GET  /v1/accounts/{id}/positions
member closes               ──► POST /v1/accounts/{id}/positions/{ticket}/close
member leaves               ──► POST /v1/accounts/{id}/disconnect  (or DELETE /v1/accounts/{id})
```

<Callout type="warn" title="Test with demo accounts">
  `fx_test_` keys are a label for your configuration, not a sandbox: they reach real brokers. Build and test the whole flow with
  **broker demo accounts** before connecting any member's real-money account.
</Callout>

## 1. Connect a member's account (once) [#1-connect-a-members-account-once]

Your app asks the member for their **MT5 account number**, their **trading password** (not the investor password), and their **broker server name** exactly as MetaTrader shows it — for example `VantageMarkets-Live` or `ICMarkets-Live02`. Send them to your backend, and from there:

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -sS -X POST "https://api.fxapis.com/v1/accounts" \
      -H "Authorization: Bearer $FXAPIS_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "login": "81234567",
        "server": "VantageMarkets-Live",
        "password": "the-members-trading-password",
        "mode": "warm_on_demand",
        "label": "member_4821"
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    account = session.post(f"{API}/v1/accounts", json={
        "login": form["login"],
        "server": form["server"],
        "password": form["password"],
        "mode": "warm_on_demand",
        "label": f"member_{member.id}",
    }).json()["data"]
    member.fxapis_account_id = account["id"]   # keep this; never keep the password
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const { data: account } = await fetch(`${API}/v1/accounts`, {
      method: "POST",
      headers,
      body: JSON.stringify({
        login: form.login,
        server: form.server,
        password: form.password,
        mode: "warm_on_demand",
        label: `member_${member.id}`,
      }),
    }).then((r) => r.json());
    await db.members.update(member.id, { fxapisAccountId: account.id }); // never store the password
    ```
  </CodeBlockTab>
</CodeBlockTabs>

* **Store the returned `id`** against the member. That is how you address their account from now on.
* **Do not store the password** anywhere on your side, and do not log the request body. We encrypt it on arrival and never return it.
* `"mode": "warm_on_demand"` keeps the account offline until it is needed and takes it offline again after a quiet period.
* `label` is yours — put your member id in it so our console and your records line up.
* The same login and server can be connected once per workspace; a second attempt returns **409** `ACCOUNT_EXISTS` with the existing account's id. A login that was disconnected can be connected again: the same account comes back, with **200**.

### Check the credentials straight away [#check-the-credentials-straight-away]

A new account has not logged in yet, so a wrong password would only surface on the member's first trade. Bring it online once while the member is still on the connect screen, and tell them the result:

<CodeBlockTabs defaultValue="Python">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Python">
    ```python
    session.post(f"{API}/v1/accounts/{account['id']}/warm")          # 202, returns at once
    state = wait_until_ready(session, account["id"], timeout=90)    # see Accounts → Polling well
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    await fetch(`${API}/v1/accounts/${account.id}/warm`, { method: "POST", headers });
    const state = await waitUntilReady(account.id, 90_000); // poll GET /v1/accounts/{id}/status every 2 s
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Usually `ready` in about 10 seconds in our measurement. If it ends in a state that needs the member instead, show them what to fix:

| State                 | Tell the member                                                                                                             |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `invalid_credentials` | The login, password or server is wrong. Check the **trading** password and the exact server name.                           |
| `trading_disabled`    | The broker has disabled trading for this login. (An investor password instead reaches `ready` and its orders are rejected.) |
| `needs_2fa`           | Their account requires two-factor sign-in. They need to turn it off for this account.                                       |
| `needs_certificate`   | Their broker requires a certificate. Contact us.                                                                            |

To fix credentials, send the corrected password to `POST /v1/accounts/{id}/password` (add `server` to correct the server name), then bring the account online again to check it — see [Connect accounts](/guides/connect-accounts#correcting-credentials). The account goes offline again by itself after 15 minutes; nothing else is needed.

### Tell the member which signals their account can follow [#tell-the-member-which-signals-their-account-can-follow]

Once the account has been online, its broker's symbols are known. [`GET /v1/accounts/{id}/instruments`](/instruments#how-a-symbol-is-chosen) says, for every instrument, which symbol it will trade there — or that the broker does not offer it. Showing that on the connect screen ("your account can follow gold and the majors, but not BTCUSD") saves a refused order later.

### Show each member their own broker's market hours [#show-each-member-their-own-brokers-market-hours]

[`GET /v1/accounts/{id}/sessions?instrument=XAUUSD`](/instruments#market-hours) says whether the market is open now at that member's broker, when it next opens or closes, and its weekly hours in UTC — read from the broker itself. "Gold is closed at your broker — opens Sunday 22:01 UTC" beats a refused order.

### Build your signal editor from the instruments [#build-your-signal-editor-from-the-instruments]

Create signals by choosing from [`GET /v1/instruments`](/instruments) — `XAUUSD`, `EURUSD`, `US30`… — rather than typing a broker symbol. Store the instrument on the signal; it is what each member's order sends.

## 2. When a member opens a signal: prepare their account [#2-when-a-member-opens-a-signal-prepare-their-account]

Bring **that member's** account online as soon as they open the signal in your app — not when the signal is published to everyone. By the time they have read it and tapped approve, their account is logged in and the order goes straight through. Only members who look at the signal use capacity.

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -sS -X POST "https://api.fxapis.com/v1/accounts/prepare" \
      -H "Authorization: Bearer $FXAPIS_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "accountIds": ["0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51"] }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    session.post(f"{API}/v1/accounts/prepare", json={"accountIds": [member.fxapis_account_id]})
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    // Fire and forget: do not make the member wait for it.
    fetch(`${API}/v1/accounts/prepare`, { method: "POST", headers, body: JSON.stringify({ accountIds: [member.fxapisAccountId] }) });
    ```
  </CodeBlockTab>
</CodeBlockTabs>

It answers **202** straight away, with a result per account: `starting`, `already_running`, `needs_operator` (the member has something to fix — see the table above), `leased_elsewhere` (busy with another request — retry in a moment), `no_secret` (no password stored — ask the member for it again), `not_found` or `failed`. Calling it again for an account that is already online is harmless. It accepts up to 200 accounts, if you ever want to prepare a group at once.

## 3. When the member approves: place the order [#3-when-the-member-approves-place-the-order]

<CodeBlockTabs defaultValue="cURL">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash
    curl -sS -X POST "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/orders/market" \
      -H "Authorization: Bearer $FXAPIS_KEY" \
      -H "Idempotency-Key: signal_9931:member_4821" \
      -H "Content-Type: application/json" \
      -d '{
        "instrument": "XAUUSD",
        "side": "buy",
        "volume": "0.05",
        "stopLoss": "2338.50",
        "takeProfit": "2371.00",
        "clientOrderId": "signal_9931"
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    response = session.post(
        f"{API}/v1/accounts/{member.fxapis_account_id}/orders/market",
        headers={"Idempotency-Key": f"signal_{signal.id}:member_{member.id}"},
        json={"instrument": signal.instrument, "side": signal.side, "volume": member.lot_size,
              "stopLoss": signal.stop_loss, "takeProfit": signal.take_profit,
              "clientOrderId": f"signal_{signal.id}"},
    )
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Node.js">
    ```js
    const response = await fetch(`${API}/v1/accounts/${member.fxapisAccountId}/orders/market`, {
      method: "POST",
      headers: { ...headers, "Idempotency-Key": `signal_${signal.id}:member_${member.id}` },
      body: JSON.stringify({
        instrument: signal.instrument, side: signal.side, volume: member.lotSize,
        stopLoss: signal.stopLoss, takeProfit: signal.takeProfit, clientOrderId: `signal_${signal.id}`,
      }),
    });
    ```
  </CodeBlockTab>
</CodeBlockTabs>

* **Use `signal id + member id` as the Idempotency-Key.** A member double-tapping, a flaky mobile connection, or your own retry can never open a second position for the same signal — the repeat gets the first answer back.
* **Volumes and prices are strings** (`"0.05"`, not `0.05`), and stops are absolute prices, not pips.
* The call waits for the broker's answer, usually within a few hundred milliseconds of the account being online. If the member approved before a `warm_on_demand` account came online, it waits for the login first (about 10 seconds). The exception is an order naming an `instrument` on an account that has never been online: its broker's symbols are not known yet, so it is refused with `SYMBOLS_NOT_SYNCED` rather than waited for — which is why bringing the account online at connect time matters.
* **Name the instrument, not the broker's symbol.** Your members' brokers spell the same market differently (`XAUUSD`, `XAUUSD.a`, `XAUUSDm`, `GOLD`). Send `instrument` from [`GET /v1/instruments`](/instruments) and each order trades that member's own symbol for it; the response's `symbol` says which. If a member's broker does not offer it, the order is refused with `INSTRUMENT_NOT_OFFERED` and nothing is sent. To trade a specific broker symbol, send `symbol` instead.

### Handling the answer [#handling-the-answer]

| Response                                                                                | What happened                                                                                                                                                                                                                   | Do this                                                                                                 |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **201** `state: "filled"`                                                               | The trade is open at `filledPrice`.                                                                                                                                                                                             | Show it to the member.                                                                                  |
| **422** `ORDER_REJECTED`                                                                | The broker refused it — nothing opened. `retcode` says why.                                                                                                                                                                     | Show the reason. If `retryable` is true (the price moved), the member can try again with a **new** key. |
| **409** `ACCOUNT_NOT_READY` or `NO_RUNTIME`                                             | The account could not come online in time, or it needs the member to fix something (see its `state`). Nothing was sent.                                                                                                         | Retry in a few seconds **with the same key** — unless the account needs attention; then ask the member. |
| **409** `SYMBOLS_NOT_SYNCED`                                                            | The account has never been online, so the `instrument` cannot be translated yet. Nothing was sent.                                                                                                                              | Bring it online (`/warm`) and retry with the same key, or send `symbol`.                                |
| **422** `INSTRUMENT_NOT_OFFERED` / `INSTRUMENT_AMBIGUOUS`, **400** `UNKNOWN_INSTRUMENT` | The instrument could not be translated safely for this member's broker. Nothing was sent.                                                                                                                                       | Show the member; for an ambiguous one, `details` lists the candidates — send the one meant as `symbol`. |
| **503** `SEND_FAILED`                                                                   | Nothing reached the broker.                                                                                                                                                                                                     | Retry **with the same key**.                                                                            |
| **503** `ORDER_UNRESOLVED`                                                              | We do not know yet if it reached the broker.                                                                                                                                                                                    | **Do not resend.** Show "confirming…" and poll `GET /v1/orders/{id}` until it settles.                  |
| **409** `IDEMPOTENCY_IN_FLIGHT`                                                         | The same approval is still being processed.                                                                                                                                                                                     | Wait a moment and retry with the same key.                                                              |
| **402** `QUOTA_EXCEEDED`                                                                | On paid plans, orders beyond the allowance are billed per order up to twice the allowance, then new opening orders are refused. On Free and on a trial they are refused at the allowance. Closing is never refused for billing. | Change plan, or wait for next month.                                                                    |

The full list is in [Errors](/errors), and the reasoning behind these outcomes is in [Trading](/trading#the-three-failures).

Runnable click-to-trade examples — prepare, approve, place and close — are in
[fxapis-examples](https://github.com/FXapis/fxapis-examples), in Python, Node/TypeScript and curl —
[the walkthrough](https://fxapis.com/blog/mt5-api-examples-python-node-curl) shows how to run them.

## 4. Showing the member their trades [#4-showing-the-member-their-trades]

```bash
curl -sS "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/positions" -H "Authorization: Bearer $FXAPIS_KEY"
```

Each position has its `symbol`, `side`, `volume`, `openPrice`, `currentPrice`, `stopLoss`, `takeProfit`, `profit`, and **`observedAt`** — when we last saw it. Positions refresh every 15 seconds or so **while the account is online**. While it is offline, what you get is the last snapshot, and `observedAt` says how old it is; a stop loss or take profit that fires at the broker in the meantime shows up the next time the account comes online.

For a live view when the member opens their trades screen, prepare their account; once its status is `ready`, positions refresh on their own within seconds. To refresh them immediately, call `reconcile` (it does nothing for an account that is offline):

```bash
curl -sS -X POST "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/reconcile" -H "Authorization: Bearer $FXAPIS_KEY"
curl -sS "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/positions" -H "Authorization: Bearer $FXAPIS_KEY"
```

For a member's history, use `GET /v1/orders?accountId=…` (what they asked for) and `GET /v1/accounts/{id}/deals` (what the broker did, including stop-loss and take-profit closes). Results come newest first, 50 at a time, with a cursor for more. See [Trading → History](/trading#history).

## 5. Closing a trade [#5-closing-a-trade]

```bash
curl -sS -X POST "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/positions/$TICKET/close" \
  -H "Authorization: Bearer $FXAPIS_KEY" \
  -H "Idempotency-Key: close_$TICKET" \
  -H "Content-Type: application/json" -d '{}'
```

`$TICKET` is the position's `brokerPositionId`. Omit `volume` to close all of it, or send `"volume": "0.02"` to close part. **Always send an Idempotency-Key** — a repeated close on a hedging account would open an opposite position. If the account is offline, the close brings it online first. Closing is never refused for billing reasons. To move a stop instead, use `POST /v1/accounts/{id}/positions/{ticket}/modify`.

## 6. When a member leaves [#6-when-a-member-leaves]

```bash
curl -sS -X POST "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID/disconnect" -H "Authorization: Bearer $FXAPIS_KEY"
```

This erases the stored password at once. Their trading history stays available to you. To remove the account and its history for good — a member who asked to be forgotten, or a record made with the wrong server name — delete it:

```bash
curl -sS -X DELETE "https://api.fxapis.com/v1/accounts/$ACCOUNT_ID" -H "Authorization: Bearer $FXAPIS_KEY"
```

Deleting cannot be undone, and is refused while the account has an order in flight or open positions (send `?force=true` to delete with positions still open at the broker). See [Accounts](/accounts#delete-v1accountsid).

## Capacity and limits [#capacity-and-limits]

* Accounts come online from a shared pool of terminals. An account stays online for 15 minutes after its last order, then frees its place. If very many members trade at the same moment and the pool is momentarily full, the order returns **409** with nothing sent — retrying a few seconds later with the same key is safe. If you expect large simultaneous volumes, tell us and we will add capacity ahead of time.
* Requests are rate-limited per workspace and per kind of work. Trading requests (orders, closes, stop changes, cancels) scale with the accounts your plan allows: twice the account allowance a minute with a burst of the allowance, never below 60 a minute with a burst of 10 — for example 500 a minute with a burst of 250 on Production, 2,000 with a burst of 1,000 on Business. If more members than that take a signal in the same minute, the rest get **429** — its `retry-after` says when, and retrying with the same key is safe. See [Errors](/errors#rate-limits).
* Your plan sets how many accounts you can connect and how many orders a month you can place. See the console's Billing page.

## Checklist before going live [#checklist-before-going-live]

* [ ] The API key lives only on your backend, in a secret store.
* [ ] Member passwords go straight to fxapis and are never stored or logged by you.
* [ ] New accounts are checked with `warm` + status while the member is still on the connect screen.
* [ ] Opening a signal calls `prepare` for that member's account.
* [ ] Every order and close carries an Idempotency-Key derived from the signal and member.
* [ ] `ORDER_UNRESOLVED` is never resent; `ACCOUNT_NOT_READY`, `NO_RUNTIME` and `SEND_FAILED` are retried with the same key.
* [ ] Positions are shown with how fresh they are (`observedAt`).
* [ ] The whole flow has been run end to end on demo accounts.
