Place MetaTrader 5 orders from Java — no MetaTrader installed
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 every failure correctly, reads positions and closes the trade — on any JVM, with no terminal.
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
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.
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.
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:executeandtrading:readscopes (create one in the console). - 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 if you are unsure which is which.
Set up
With Maven or Gradle, add the one dependency:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>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:
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 itKeep the key and password in the environment or a secret manager — never in source code, and never in a log line.
The complete program
Save this as 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.13419Authentication 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 and 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.
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.
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.
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.
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.
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
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
- One order across many accounts — one call, up to 500 accounts on Enterprise (Starter 10, Scale 100).
- Monitoring prop-firm accounts — rule checks from deals and positions.
- Errors and the API reference.
Place MetaTrader 5 orders from Go — no MetaTrader installed
A complete Go program using net/http that connects an MT5 account, places a market order with an idempotency key, retries safely, never resends an unresolved order, reads positions and closes the trade — a single static binary, no terminal, no cgo.
Place MetaTrader 5 orders from Ruby — no MetaTrader installed
A complete Ruby program using Net::HTTP that connects an MT5 account, places a market order with an idempotency key, retries safely, never resends an unresolved order, reads positions and closes the trade — from Rails, Sidekiq or a plain script, with no terminal.