# Place MetaTrader 5 orders from C# and .NET — no MetaTrader installed

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

> A complete .NET 8 console program using HttpClient that connects an MT5 account, places a market order with an idempotency key, handles every failure…



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.

<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]

* The .NET 8 SDK or newer.
* 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]

```bash
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 it
```

No 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 [#the-complete-program]

Replace `Program.cs` with this and run `dotnet run`.

```csharp title="Program.cs"
// 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.13419
```

## Authentication and the HttpClient [#authentication-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](/authentication) and [Errors](/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](/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; 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](/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 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](/accounts#states).

## Placing the order safely [#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](/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]

* [Signals and click-to-trade](/signals) — a full member-facing integration.
* [One order across many accounts](/guides/multi-account-orders) — one call, up to 500 accounts on Enterprise (Starter 10, Scale 100).
* [Errors](/errors) and the [API reference](/api-reference).
