# Place MetaTrader 5 orders from Java — no MetaTrader installed

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

> A complete Java 17 program using java.net.http.HttpClient and Jackson that connects an MT5 account, places a market order with an idempotency key, handles…



This guide builds one Java program that trades a MetaTrader 5 account end to
end: connect the account, bring it online, place a market order, read the
position, close it, take the account offline. It uses the JDK's own
`java.net.http.HttpClient` and [Jackson](https://github.com/FasterXML/jackson)
for JSON. There is no MetaTrader terminal on your side — it runs in our cloud —
so the program runs on any JVM: a Spring Boot service, a batch job, Linux or
macOS.

<Callout type="warn" title="Use a demo account">
  `fx_test_` keys are a label, not a sandbox — every key reaches a real broker. Run this with a
  **broker demo account** until you are sure your code does what you expect.
</Callout>

## What you need [#what-you-need]

* JDK 17 or newer.
* Jackson databind 2.x (`com.fasterxml.jackson.core:jackson-databind`) on the classpath.
* An fxapis API key with the `accounts:write`, `trading:execute` and `trading:read` scopes
  ([create one in the console](https://fxapis.com/dashboard/keys)).
* An MT5 **demo** account: its login number, its **trading** password (not the investor password),
  and the server name exactly as MetaTrader shows it. See
  [Connect MT5 accounts](/guides/connect-accounts) if you are unsure which is which.

## Set up [#set-up]

With Maven or Gradle, add the one dependency:

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

    <CodeBlockTabsTrigger value="Gradle">
      Gradle
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Maven">
    ```xml
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.18.2</version>
    </dependency>
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Gradle">
    ```kotlin
    implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Or, to try it as a single file, put the three Jackson jars (`jackson-databind`,
`jackson-core`, `jackson-annotations`) in a `lib` folder and run
`java -cp "lib/*" Trade.java`. Then set the environment:

```bash
export FXAPIS_KEY="fx_test_…"          # your API key
export MT5_LOGIN="26177561"
export MT5_SERVER="VantageMarkets-Demo"
export MT5_PASSWORD="…"                # the trading password
export MT5_SYMBOL="EURUSD"             # as your broker names it
```

Keep the key and password in the environment or a secret manager — never in
source code, and never in a log line.

## The complete program [#the-complete-program]

Save this as `Trade.java`.

```java title="Trade.java"
// Connect an MT5 account through fxapis, open a small trade, and close it.
// Run it against a broker DEMO account. Every fxapis key reaches a real broker.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

public class Trade {
    static final Set<String> NEEDS_ATTENTION =
            Set.of("invalid_credentials", "trading_disabled", "needs_2fa", "needs_certificate");
    // Codes that mean "nothing was placed yet — ask again with the SAME key".
    static final Set<String> RETRY_SAME_KEY =
            Set.of("SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED");

    static final ObjectMapper JSON = new ObjectMapper();
    static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
    static final String API = System.getenv().getOrDefault("FXAPIS_API", "https://api.fxapis.com");
    static final String KEY = required("FXAPIS_KEY");

    /** Every failure the API reports. Match on code; the message is for people. */
    static final class ApiError extends RuntimeException {
        final int status;
        final String code;
        final JsonNode details;

        ApiError(int status, String code, String message, JsonNode details) {
            super(status + " " + code + ": " + message);
            this.status = status;
            this.code = code;
            this.details = details;
        }
    }

    static String required(String name) {
        String value = System.getenv(name);
        if (value == null || value.isEmpty()) throw new IllegalStateException("Set " + name);
        return value;
    }

    static HttpResponse<String> send(String method, String path, JsonNode body, Map<String, String> headers,
                                     Duration timeout) throws IOException, InterruptedException {
        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(API + path))
                .timeout(timeout)
                .header("Authorization", "Bearer " + KEY);
        if (body != null) {
            request.header("Content-Type", "application/json") // only with a body
                    .method(method, HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)));
        } else {
            request.method(method, HttpRequest.BodyPublishers.noBody());
        }
        headers.forEach(request::header);
        return HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString());
    }

    /** The error object of a failed response, or null if the body is not ours (a proxy page). */
    static JsonNode errorOf(HttpResponse<String> response) {
        try {
            JsonNode error = JSON.readTree(response.body()).path("error");
            return error.hasNonNull("code") ? error : null;
        } catch (IOException notJson) {
            return null;
        }
    }

    static ApiError fail(HttpResponse<String> response) {
        JsonNode error = errorOf(response);
        if (error == null) return new ApiError(response.statusCode(), "HTTP_ERROR", response.body(), null);
        return new ApiError(response.statusCode(), error.get("code").asText(), error.path("message").asText(),
                error.path("details"));
    }

    /** Sends a request and returns `data`, or throws ApiError. */
    static JsonNode call(String method, String path, JsonNode body) throws IOException, InterruptedException {
        HttpResponse<String> response = send(method, path, body, Map.of(), Duration.ofSeconds(30));
        if (response.statusCode() >= 300) throw fail(response);
        return JSON.readTree(response.body()).get("data");
    }

    /** Stores the account at fxapis. It does not log in yet. */
    static JsonNode connectAccount(String login, String server, String password) throws Exception {
        ObjectNode body = JSON.createObjectNode()
                .put("login", login)
                .put("server", server)
                .put("password", password)
                .put("mode", "warm_on_demand")
                .put("label", "java-guide");
        try {
            return call("POST", "/v1/accounts", body);
        } catch (ApiError e) {
            if (!e.code.equals("ACCOUNT_EXISTS")) throw e;
            // Connected on an earlier run: find it instead of connecting it twice.
            for (JsonNode account : call("GET", "/v1/accounts", null)) {
                if (account.get("login").asText().equals(login) && account.get("server").asText().equals(server)) {
                    return account;
                }
            }
            throw e;
        }
    }

    /** Starts the broker login, then polls until the account is ready to trade. */
    static void bringOnline(String accountId, Duration timeout) throws Exception {
        call("POST", "/v1/accounts/" + accountId + "/warm", null); // 409 NEEDS_OPERATOR / NO_SECRET throws
        Instant deadline = Instant.now().plus(timeout);
        while (Instant.now().isBefore(deadline)) {
            JsonNode status = call("GET", "/v1/accounts/" + accountId + "/status", null);
            String state = status.get("state").asText();
            if (state.equals("ready")) return;
            if (NEEDS_ATTENTION.contains(state)) {
                // Not retryable. Repeated failed logins are how brokers lock accounts.
                throw new IllegalStateException("account needs attention: " + state + " (" + status.path("detail").asText() + ")");
            }
            Thread.sleep(2_000);
        }
        throw new IllegalStateException("account not ready after " + timeout.toSeconds() + "s");
    }

    /** After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it. */
    static JsonNode waitForOutcome(String orderId, Duration timeout) throws Exception {
        Instant deadline = Instant.now().plus(timeout);
        while (Instant.now().isBefore(deadline)) {
            JsonNode order = call("GET", "/v1/orders/" + orderId, null);
            if (!order.get("state").asText().equals("unknown")) return order;
            Thread.sleep(5_000);
        }
        throw new IllegalStateException("order " + orderId + " is still unknown. Do not resend it; check it again later.");
    }

    /** Places an order (or a close) exactly once, however many times the request is retried. */
    static JsonNode sendOrder(String path, JsonNode body) throws Exception {
        String key = UUID.randomUUID().toString(); // one key per order, reused on every retry
        int attempts = 6;
        for (int attempt = 0; attempt < attempts; attempt++) {
            long backoffMs = Math.min(1L << attempt, 10L) * 1000;
            HttpResponse<String> response;
            try {
                // 90 s, because an order waits for the broker — and for the login, if the account was offline.
                response = send("POST", path, body, Map.of("Idempotency-Key", key), Duration.ofSeconds(90));
            } catch (IOException e) { // includes HttpTimeoutException
                Thread.sleep(backoffMs); // we never saw the answer; the same key makes asking again safe
                continue;
            }

            if (response.statusCode() == 201) return JSON.readTree(response.body()).get("data");

            JsonNode error = errorOf(response);
            if (error == null && response.statusCode() >= 500) {
                Thread.sleep(backoffMs); // not our error body: a gateway hiccup
                continue;
            }
            if (error == null) throw fail(response);

            String code = error.get("code").asText();
            if (code.equals("ORDER_UNRESOLVED")) {
                return waitForOutcome(error.get("details").get(0).get("orderId").asText(), Duration.ofMinutes(5));
            }
            if (RETRY_SAME_KEY.contains(code)) {
                long retryAfter = response.headers().firstValueAsLong("retry-after").orElse(0);
                Thread.sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs);
                continue;
            }
            throw fail(response); // ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
        }
        throw new IllegalStateException("gave up after " + attempts + " attempts; retrying later with key " + key + " is still safe");
    }

    static String text(JsonNode node, String field) {
        return node.path(field).asText("-");
    }

    public static void main(String[] args) throws Exception {
        String login = required("MT5_LOGIN");
        String server = required("MT5_SERVER");
        String symbol = System.getenv().getOrDefault("MT5_SYMBOL", "EURUSD");

        JsonNode account = connectAccount(login, server, required("MT5_PASSWORD"));
        String id = account.get("id").asText();
        System.out.println("account " + id + " (" + text(account, "state") + ")");

        bringOnline(id, Duration.ofMinutes(2));
        System.out.println("online");

        JsonNode order;
        try {
            order = sendOrder("/v1/accounts/" + id + "/orders/market", JSON.createObjectNode()
                    .put("symbol", symbol)
                    .put("side", "buy")
                    .put("volume", "0.01")); // a string, never a double
        } catch (ApiError e) {
            if (!e.code.equals("ORDER_REJECTED")) throw e;
            System.out.println("rejected by the broker: " + e.getMessage());
            System.exit(1);
            return;
        }
        String state = text(order, "state");
        System.out.println("order " + text(order, "id") + ": " + state + " at " + text(order, "filledPrice"));
        if (!state.equals("filled") && !state.equals("partially_filled")) System.exit(1);

        // Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
        call("POST", "/v1/accounts/" + id + "/reconcile", null);
        for (JsonNode p : call("GET", "/v1/accounts/" + id + "/positions", null)) {
            System.out.printf("  #%s %s %s %s @ %s profit %s (seen %s)%n",
                    text(p, "brokerPositionId"), text(p, "side"), text(p, "volume"), text(p, "symbol"),
                    text(p, "openPrice"), text(p, "profit"), text(p, "observedAt"));
        }

        String ticket = text(order, "brokerPositionId");
        JsonNode close = sendOrder("/v1/accounts/" + id + "/positions/" + ticket + "/close", JSON.createObjectNode());
        System.out.println("closed: " + text(close, "state") + " at " + text(close, "filledPrice"));

        // Offline now rather than after the idle window. The stored password is kept.
        call("POST", "/v1/accounts/" + id + "/cool", null);

        if ("1".equals(System.getenv("FXAPIS_DISCONNECT"))) {
            // Erases the stored password for good. Only when you are done with this account.
            JsonNode result = call("POST", "/v1/accounts/" + id + "/disconnect", null);
            System.out.println("disconnected, credentials removed: " + result.path("credentialsRemoved").asBoolean());
        }
    }
}
```

A run against a demo account prints something like:

```
account 0d7c1f5e-4b6a-4c1e-9f0a-2d9e8c7b6a51 (created)
online
order b1c7…9e2a: filled at 1.13426
  #2109584418 buy 0.01 EURUSD @ 1.13426 profit -0.07 (seen 2026-09-30T10:14:03.512Z)
closed: filled at 1.13419
```

## Authentication and the HttpClient [#authentication-and-the-httpclient]

`send` puts `Authorization: Bearer <key>` on every request and a JSON content
type only when there is a body. One `HttpClient` is shared for the life of the JVM; it is
thread-safe and pools connections. `call` unwraps the `{ "data": … }`
envelope and throws an `ApiError` carrying the stable `code`. See
[Authentication](/authentication) and [Errors](/errors).

The program reads responses as `JsonNode` to stay short. In an application,
map them to records — every field is listed in the [API reference](/api-reference).

## Connecting the account [#connecting-the-account]

`POST /v1/accounts` stores the account and encrypts its password. It does not
log in, so it answers at once with the account in `created`. Keep the returned
`id` against your user; never keep the password.

A login and server can be connected once per workspace; a second run gets
**409** `ACCOUNT_EXISTS` and finds the account with `GET /v1/accounts`.
Reference: [Connect an MT5 account](/api-reference/accounts/connect-an-mt5-account).

## Bringing it online [#bringing-it-online]

`POST /v1/accounts/{id}/warm` answers **202** at once while the broker login
runs. `bringOnline` polls `GET /v1/accounts/{id}/status` every two seconds until
`ready` — usually about ten seconds in our tests — and stops at once on a needs-attention
state (`invalid_credentials`, `trading_disabled`, `needs_2fa`,
`needs_certificate`), which needs a person rather than a retry. See
[Accounts](/accounts#states).

## Placing the order safely [#placing-the-order-safely]

`sendOrder` creates one `Idempotency-Key` per order with `UUID.randomUUID()`
and sends the **same** key on every retry, so the order is placed at most once.
Keys are remembered for 24 hours.

Volumes and prices are strings — `"0.01"`. Compute sizes with `BigDecimal` and
send `value.toPlainString()`; a `double` has already lost precision by the time
you format it.

| Response                                  | What it means                                                     | What `sendOrder` does                                                                                   |
| ----------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **201**                                   | The broker answered; `state` is `filled` (or `partially_filled`). | Returns the order.                                                                                      |
| **422** `ORDER_REJECTED`                  | The broker refused it. Nothing executed.                          | Throws. If `details[0].retryable` is true (the price moved), a *new* decision with a *new* key is fine. |
| **409** `ACCOUNT_NOT_READY`, `NO_RUNTIME` | The account was not online in time. Nothing was sent.             | Retries with the same key.                                                                              |
| **409** `IDEMPOTENCY_IN_FLIGHT`           | The first attempt with this key is still running.                 | Retries with the same key.                                                                              |
| **502** `SEND_FAILED`                     | Proven never to have reached the broker.                          | Retries with the same key.                                                                              |
| **502** `ORDER_UNRESOLVED`                | We do not know yet whether it executed.                           | **Never resends.** Polls `GET /v1/orders/{id}` until it leaves `unknown`.                               |
| **429** `RATE_LIMITED`                    | Too many requests of this kind.                                   | Waits `retry-after`, same key.                                                                          |
| `IOException` or `HttpTimeoutException`   | You did not see the answer.                                       | Retries with the same key; the server replays the first answer.                                         |

If you use Spring Retry or Resilience4j, give them these rules rather than
"retry on 5xx": a blanket rule would resend `ORDER_UNRESOLVED`, which must never
be resent. More in [Idempotent MT5 orders](/guides/idempotency).

## Reading positions [#reading-positions]

`GET /v1/accounts/{id}/positions` lists open positions with
`brokerPositionId`, `side`, `volume`, `openPrice`, `currentPrice`, `stopLoss`,
`takeProfit`, `profit` and `observedAt`. They refresh about every 15 seconds
while the account is online; `POST /v1/accounts/{id}/reconcile` refreshes them
now. Reference: [Positions on an account](/api-reference/trading/positions-on-an-account).

## Closing the position [#closing-the-position]

`POST /v1/accounts/{id}/positions/{ticket}/close` with `{}` closes it all;
`{"volume": "0.005"}` closes part. The ticket is the order's
`brokerPositionId`. The close goes through `sendOrder` with its own key,
because a repeated close on a hedging account would open an opposite position.

A close of a position opened moments ago refreshes positions first;
`POSITION_NOT_FOUND` means it is already closed. The program's `reconcile`
call after the fill is optional — it only makes the positions listing current
at once.

## Offline and disconnect [#offline-and-disconnect]

`POST /cool` takes the account offline and keeps its stored password; left
alone, a `warm_on_demand` account goes offline after 15 idle minutes. Stops and
take profits keep working at the broker. `POST /disconnect` erases the stored
password for good — use it when a customer leaves. The program calls it only
with `FXAPIS_DISCONNECT=1`, so you can run it repeatedly. Connecting a disconnected login again brings the same account back, with its history.

## Next steps [#next-steps]

* [One order across many accounts](/guides/multi-account-orders) — one call, up to 500 accounts on Enterprise (Starter 10, Scale 100).
* [Monitoring prop-firm accounts](/guides/prop-firms) — rule checks from deals and positions.
* [Errors](/errors) and the [API reference](/api-reference).
