Place MetaTrader 5 orders from C# and .NET — no MetaTrader installed
A complete .NET 8 console program using HttpClient that connects an MT5 account, places a market order with an idempotency key, handles every failure correctly, reads positions and closes the trade — on Windows, Linux or macOS, with no terminal.
This guide builds one C# console 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 only HttpClient and
System.Text.Json from the base library, and there is no MetaTrader terminal
on your side — it runs in our cloud — so the same program runs on Windows,
Linux, macOS, in a container or an Azure Function.
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
- The .NET 8 SDK or newer.
- 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
dotnet new console -n Mt5Trade && cd Mt5Trade
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 itNo NuGet packages are needed. In production, read the key and passwords from user secrets, environment variables or a vault — never from source code, and never write them to a log.
The complete program
Replace Program.cs with this and run dotnet run.
// 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.
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var api = Environment.GetEnvironmentVariable("FXAPIS_API") ?? "https://api.fxapis.com";
// One HttpClient for the life of the process. 90 s, because an order waits for the broker.
using var http = new HttpClient { BaseAddress = new Uri(api), Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Fx.Required("FXAPIS_KEY"));
var fx = new Fx(http);
var login = Fx.Required("MT5_LOGIN");
var server = Fx.Required("MT5_SERVER");
var symbol = Environment.GetEnvironmentVariable("MT5_SYMBOL") ?? "EURUSD";
var account = await fx.ConnectAccount(login, server, Fx.Required("MT5_PASSWORD"));
var id = (string)account["id"]!;
Console.WriteLine($"account {id} ({account["state"]})");
await fx.BringOnline(id);
Console.WriteLine("online");
JsonNode order;
try
{
order = await fx.SendOrder($"/v1/accounts/{id}/orders/market",
new JsonObject { ["symbol"] = symbol, ["side"] = "buy", ["volume"] = "0.01" });
}
catch (ApiError e) when (e.Code == "ORDER_REJECTED")
{
Console.WriteLine($"rejected by the broker: {e.Message}");
return 1;
}
Console.WriteLine($"order {order["id"]}: {order["state"]} at {order["filledPrice"]}");
if ((string?)order["state"] is not ("filled" or "partially_filled")) return 1;
// Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
await fx.Call(HttpMethod.Post, $"/v1/accounts/{id}/reconcile");
foreach (var p in (await fx.Call(HttpMethod.Get, $"/v1/accounts/{id}/positions")).AsArray())
{
Console.WriteLine($" #{p!["brokerPositionId"]} {p["side"]} {p["volume"]} {p["symbol"]} @ {p["openPrice"]} " +
$"profit {p["profit"]} (seen {p["observedAt"]})");
}
var close = await fx.SendOrder($"/v1/accounts/{id}/positions/{order["brokerPositionId"]}/close", new JsonObject());
Console.WriteLine($"closed: {close["state"]} at {close["filledPrice"]}");
// Offline now rather than after the idle window. The stored password is kept.
await fx.Call(HttpMethod.Post, $"/v1/accounts/{id}/cool");
if (Environment.GetEnvironmentVariable("FXAPIS_DISCONNECT") == "1")
{
// Erases the stored password for good. Only when you are done with this account.
var result = await fx.Call(HttpMethod.Post, $"/v1/accounts/{id}/disconnect");
Console.WriteLine($"disconnected, credentials removed: {result["credentialsRemoved"]}");
}
return 0;
sealed class ApiError(int status, string code, string message, JsonArray? details)
: Exception($"{status} {code}: {message}")
{
public int Status { get; } = status;
public string Code { get; } = code;
public JsonArray Details { get; } = details ?? new JsonArray();
}
sealed class Fx(HttpClient http)
{
static readonly HashSet<string> NeedsAttention =
["invalid_credentials", "trading_disabled", "needs_2fa", "needs_certificate"];
// Codes that mean "nothing was placed yet — ask again with the SAME key".
static readonly HashSet<string> RetrySameKey =
["SEND_FAILED", "IDEMPOTENCY_IN_FLIGHT", "ACCOUNT_NOT_READY", "NO_RUNTIME", "RATE_LIMITED"];
public static string Required(string name) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
? value
: throw new InvalidOperationException($"Set {name}");
/// <summary>The error object of a failed response, or null if the body is not ours (a proxy page).</summary>
static async Task<JsonObject?> ErrorOf(HttpResponseMessage response)
{
try
{
return JsonNode.Parse(await response.Content.ReadAsStringAsync())?["error"] as JsonObject;
}
catch (JsonException)
{
return null;
}
}
static async Task<ApiError> Fail(HttpResponseMessage response)
{
var error = await ErrorOf(response);
return new ApiError((int)response.StatusCode,
(string?)error?["code"] ?? "HTTP_ERROR",
(string?)error?["message"] ?? response.ReasonPhrase ?? "",
error?["details"] as JsonArray);
}
static StringContent Json(JsonNode body) => new(body.ToJsonString(), Encoding.UTF8, "application/json");
/// <summary>Sends a request and returns <c>data</c>, or throws ApiError.</summary>
public async Task<JsonNode> Call(HttpMethod method, string path, JsonNode? body = null)
{
using var request = new HttpRequestMessage(method, path);
if (body is not null) request.Content = Json(body); // no body, no JSON content type
using var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode) throw await Fail(response);
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["data"]!;
}
/// <summary>Stores the account at fxapis. It does not log in yet.</summary>
public async Task<JsonNode> ConnectAccount(string login, string server, string password)
{
try
{
return await Call(HttpMethod.Post, "/v1/accounts", new JsonObject
{
["login"] = login,
["server"] = server,
["password"] = password,
["mode"] = "warm_on_demand",
["label"] = "csharp-guide",
});
}
catch (ApiError error) when (error.Code == "ACCOUNT_EXISTS")
{
// Connected on an earlier run: find it instead of connecting it twice.
var accounts = (await Call(HttpMethod.Get, "/v1/accounts")).AsArray();
var existing = accounts.FirstOrDefault(a => (string?)a?["login"] == login && (string?)a?["server"] == server);
if (existing is not null) return existing;
throw;
}
}
/// <summary>Starts the broker login, then polls until the account is ready to trade.</summary>
public async Task BringOnline(string accountId, int timeoutSeconds = 120)
{
await Call(HttpMethod.Post, $"/v1/accounts/{accountId}/warm"); // 409 NEEDS_OPERATOR / NO_SECRET throws
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
var status = await Call(HttpMethod.Get, $"/v1/accounts/{accountId}/status");
var state = (string)status["state"]!;
if (state == "ready") return;
if (NeedsAttention.Contains(state))
{
// Not retryable. Repeated failed logins are how brokers lock accounts.
throw new InvalidOperationException($"account needs attention: {state} ({status["detail"]})");
}
await Task.Delay(TimeSpan.FromSeconds(2));
}
throw new TimeoutException($"account not ready after {timeoutSeconds}s");
}
/// <summary>After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it.</summary>
public async Task<JsonNode> WaitForOutcome(string orderId, int timeoutSeconds = 300)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
var order = await Call(HttpMethod.Get, $"/v1/orders/{orderId}");
if ((string?)order["state"] != "unknown") return order;
await Task.Delay(TimeSpan.FromSeconds(5));
}
throw new InvalidOperationException($"order {orderId} is still unknown. Do not resend it; check it again later.");
}
/// <summary>Places an order (or a close) exactly once, however many times the request is retried.</summary>
public async Task<JsonNode> SendOrder(string path, JsonObject body, int attempts = 6)
{
var key = Guid.NewGuid().ToString(); // one key per order, reused on every retry
for (var attempt = 0; attempt < attempts; attempt++)
{
var backoff = TimeSpan.FromSeconds(Math.Min(Math.Pow(2, attempt), 10));
HttpResponseMessage response;
try
{
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = Json(body) };
request.Headers.Add("Idempotency-Key", key);
response = await http.SendAsync(request);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
await Task.Delay(backoff); // we never saw the answer; the same key makes asking again safe
continue;
}
using (response)
{
if (response.StatusCode == HttpStatusCode.Created)
{
return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["data"]!;
}
var error = await ErrorOf(response);
if (error is null && (int)response.StatusCode >= 500)
{
await Task.Delay(backoff); // not our error body: a gateway hiccup
continue;
}
if (error is null) throw await Fail(response);
var code = (string)error["code"]!;
if (code == "ORDER_UNRESOLVED")
{
return await WaitForOutcome((string)error["details"]![0]!["orderId"]!);
}
if (RetrySameKey.Contains(code))
{
await Task.Delay(response.Headers.RetryAfter?.Delta ?? backoff);
continue;
}
throw await Fail(response); // ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
}
}
throw new InvalidOperationException($"gave up after {attempts} attempts; retrying later with key {key} is still safe");
}
}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
One HttpClient carries the Authorization: Bearer … header for every call.
In ASP.NET Core, register it with IHttpClientFactory (a typed client) instead
of creating one per request. Call unwraps the { "data": … } envelope and
turns failures into an ApiError with the stable Code — match on it, never
on the message. It only sets a JSON content type when there is a body. See
Authentication and Errors.
The program uses JsonNode to stay short. In a real project, define records
for accounts, orders and positions and deserialise with
JsonSerializerDefaults.Web; every field name is 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; do not keep the password anywhere.
A login and server can be connected once per workspace, so 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 later. A needs-attention state —
invalid_credentials, trading_disabled, needs_2fa, needs_certificate —
ends the wait immediately, because it needs a person rather than a retry. See
Accounts.
Placing the order safely
SendOrder creates one Idempotency-Key per order with Guid.NewGuid() 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". If you compute sizes, use decimal
and value.ToString(CultureInfo.InvariantCulture); never double, and never
the current culture, which may write 0,01.
| 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. |
HttpRequestException or a timeout | You did not see the answer. | Retries with the same key; the server replays the first answer. |
Do not put fxapis order calls behind a generic Polly retry policy that retries
every 5xx: that would resend ORDER_UNRESOLVED, which is the one answer that
must never be resent. If you use Polly, give it the same rules as the table.
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
- Signals and click-to-trade — a full member-facing integration.
- One order across many accounts — one call, up to 500 accounts on Enterprise (Starter 10, Scale 100).
- Errors and the API reference.
Place MetaTrader 5 orders from PHP — no MetaTrader installed
A complete PHP program using Guzzle that connects an MT5 account, places a market order with an idempotency key, handles SEND_FAILED and ORDER_UNRESOLVED correctly, reads positions and closes the trade — from any PHP host, with no terminal.
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.