# Place MetaTrader 5 orders from Go — no MetaTrader installed

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

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



This guide builds one Go 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 the standard library —
`net/http` and `encoding/json` — and there is no MetaTrader terminal on your
side; it runs in our cloud. The result is a single static binary you can ship
in a scratch container.

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

* Go 1.22 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
mkdir mt5-trade && cd mt5-trade
go mod init example.com/mt5-trade

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

There are no modules to fetch. Keep the key and password in the environment or
a secret manager — never in source, never in a log.

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

Save this as `main.go` and run `go run .`.

```go title="main.go"
// 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.
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

var needsAttention = map[string]bool{
	"invalid_credentials": true, "trading_disabled": true, "needs_2fa": true, "needs_certificate": true,
}

// Codes that mean "nothing was placed yet — ask again with the SAME key".
var retrySameKey = map[string]bool{
	"SEND_FAILED": true, "IDEMPOTENCY_IN_FLIGHT": true, "ACCOUNT_NOT_READY": true, "NO_RUNTIME": true, "RATE_LIMITED": true,
}

type Account struct {
	ID     string `json:"id"`
	Login  string `json:"login"`
	Server string `json:"server"`
	State  string `json:"state"`
}

type Status struct {
	State  string  `json:"state"`
	Detail *string `json:"detail"`
}

type Order struct {
	ID               string  `json:"id"`
	State            string  `json:"state"`
	FilledPrice      *string `json:"filledPrice"`
	BrokerPositionID *string `json:"brokerPositionId"`
}

type Position struct {
	BrokerPositionID string  `json:"brokerPositionId"`
	Symbol           string  `json:"symbol"`
	Side             string  `json:"side"`
	Volume           *string `json:"volume"`
	OpenPrice        *string `json:"openPrice"`
	Profit           *string `json:"profit"`
	ObservedAt       string  `json:"observedAt"`
}

// APIError is every failure the API reports. Match on Code; Message is for people.
type APIError struct {
	Status  int              `json:"-"`
	Code    string           `json:"code"`
	Message string           `json:"message"`
	Details []map[string]any `json:"details"`
}

func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }

type Client struct {
	base string
	key  string
	http *http.Client
}

// send makes one request and returns the status, headers and raw body.
func (c *Client) send(method, path string, body any, headers map[string]string) (*http.Response, []byte, error) {
	var reader io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return nil, nil, err
		}
		reader = bytes.NewReader(encoded)
	}
	req, err := http.NewRequest(method, c.base+path, reader)
	if err != nil {
		return nil, nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.key)
	if body != nil {
		req.Header.Set("Content-Type", "application/json") // only with a body
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(resp.Body)
	return resp, raw, err
}

// errorOf returns the API's error, or nil if the body is not ours (a proxy page).
func errorOf(status int, raw []byte) *APIError {
	var envelope struct {
		Error *APIError `json:"error"`
	}
	if json.Unmarshal(raw, &envelope) != nil || envelope.Error == nil || envelope.Error.Code == "" {
		return nil
	}
	envelope.Error.Status = status
	return envelope.Error
}

// call sends a request and decodes `data` into out, or returns an *APIError.
func (c *Client) call(method, path string, body, out any) error {
	resp, raw, err := c.send(method, path, body, nil)
	if err != nil {
		return err
	}
	if resp.StatusCode >= 300 {
		if apiErr := errorOf(resp.StatusCode, raw); apiErr != nil {
			return apiErr
		}
		return &APIError{Status: resp.StatusCode, Code: "HTTP_ERROR", Message: resp.Status}
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(raw, &struct {
		Data any `json:"data"`
	}{Data: out})
}

// ConnectAccount stores the account at fxapis. It does not log in yet.
func (c *Client) ConnectAccount(login, server, password string) (*Account, error) {
	var account Account
	err := c.call("POST", "/v1/accounts", map[string]string{
		"login": login, "server": server, "password": password,
		"mode": "warm_on_demand", "label": "go-guide",
	}, &account)
	var apiErr *APIError
	if errors.As(err, &apiErr) && apiErr.Code == "ACCOUNT_EXISTS" {
		// Connected on an earlier run: find it instead of connecting it twice.
		var accounts []Account
		if err := c.call("GET", "/v1/accounts", nil, &accounts); err != nil {
			return nil, err
		}
		for _, a := range accounts {
			if a.Login == login && a.Server == server {
				return &a, nil
			}
		}
	}
	if err != nil {
		return nil, err
	}
	return &account, nil
}

// BringOnline starts the broker login, then polls until the account is ready to trade.
func (c *Client) BringOnline(accountID string, timeout time.Duration) error {
	if err := c.call("POST", "/v1/accounts/"+accountID+"/warm", nil, nil); err != nil {
		return err // 409 NEEDS_OPERATOR or NO_SECRET: a person has to act
	}
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		var status Status
		if err := c.call("GET", "/v1/accounts/"+accountID+"/status", nil, &status); err != nil {
			return err
		}
		if status.State == "ready" {
			return nil
		}
		if needsAttention[status.State] {
			// Not retryable. Repeated failed logins are how brokers lock accounts.
			return fmt.Errorf("account needs attention: %s", status.State)
		}
		time.Sleep(2 * time.Second)
	}
	return fmt.Errorf("account not ready after %s", timeout)
}

// WaitForOutcome is for ORDER_UNRESOLVED: never resend, poll until the broker's record settles it.
func (c *Client) WaitForOutcome(orderID string, timeout time.Duration) (*Order, error) {
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		var order Order
		if err := c.call("GET", "/v1/orders/"+orderID, nil, &order); err != nil {
			return nil, err
		}
		if order.State != "unknown" {
			return &order, nil
		}
		time.Sleep(5 * time.Second)
	}
	return nil, fmt.Errorf("order %s is still unknown; do not resend it, check it again later", orderID)
}

// SendOrder places an order (or a close) exactly once, however many times the request is retried.
func (c *Client) SendOrder(path string, body map[string]string) (*Order, error) {
	key := newUUID() // one key per order, reused on every retry
	for attempt := 0; attempt < 6; attempt++ {
		backoff := time.Duration(min(1<<attempt, 10)) * time.Second
		resp, raw, err := c.send("POST", path, body, map[string]string{"Idempotency-Key": key})
		if err != nil {
			time.Sleep(backoff) // we never saw the answer; the same key makes asking again safe
			continue
		}
		if resp.StatusCode == http.StatusCreated {
			var envelope struct {
				Data Order `json:"data"`
			}
			if err := json.Unmarshal(raw, &envelope); err != nil {
				return nil, err
			}
			return &envelope.Data, nil
		}

		apiErr := errorOf(resp.StatusCode, raw)
		if apiErr == nil && resp.StatusCode >= 500 {
			time.Sleep(backoff) // not our error body: a gateway hiccup
			continue
		}
		if apiErr == nil {
			return nil, &APIError{Status: resp.StatusCode, Code: "HTTP_ERROR", Message: resp.Status}
		}
		if apiErr.Code == "ORDER_UNRESOLVED" {
			orderID, _ := apiErr.Details[0]["orderId"].(string)
			return c.WaitForOutcome(orderID, 5*time.Minute)
		}
		if retrySameKey[apiErr.Code] {
			if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
				backoff = time.Duration(seconds) * time.Second
			}
			time.Sleep(backoff)
			continue
		}
		return nil, apiErr // ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
	}
	return nil, fmt.Errorf("gave up after 6 attempts; retrying later with key %s is still safe", key)
}

func newUUID() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	b[6] = b[6]&0x0f | 0x40 // version 4
	b[8] = b[8]&0x3f | 0x80 // RFC 4122 variant
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

func env(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	if fallback == "" {
		fmt.Fprintf(os.Stderr, "set %s\n", name)
		os.Exit(2)
	}
	return fallback
}

func str(p *string) string {
	if p == nil {
		return "-"
	}
	return *p
}

func run() error {
	c := &Client{
		base: env("FXAPIS_API", "https://api.fxapis.com"),
		key:  env("FXAPIS_KEY", ""),
		// 90 s, because an order waits for the broker — and for the login, if the account was offline.
		http: &http.Client{Timeout: 90 * time.Second},
	}
	login, server := env("MT5_LOGIN", ""), env("MT5_SERVER", "")
	symbol := env("MT5_SYMBOL", "EURUSD")

	account, err := c.ConnectAccount(login, server, env("MT5_PASSWORD", ""))
	if err != nil {
		return err
	}
	fmt.Printf("account %s (%s)\n", account.ID, account.State)

	if err := c.BringOnline(account.ID, 2*time.Minute); err != nil {
		return err
	}
	fmt.Println("online")

	order, err := c.SendOrder("/v1/accounts/"+account.ID+"/orders/market",
		map[string]string{"symbol": symbol, "side": "buy", "volume": "0.01"})
	var apiErr *APIError
	if errors.As(err, &apiErr) && apiErr.Code == "ORDER_REJECTED" {
		return fmt.Errorf("rejected by the broker: %w", err)
	}
	if err != nil {
		return err
	}
	fmt.Printf("order %s: %s at %s\n", order.ID, order.State, str(order.FilledPrice))
	if order.State != "filled" && order.State != "partially_filled" {
		return fmt.Errorf("order ended %s", order.State)
	}

	// Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
	if err := c.call("POST", "/v1/accounts/"+account.ID+"/reconcile", nil, nil); err != nil {
		return err
	}
	var positions []Position
	if err := c.call("GET", "/v1/accounts/"+account.ID+"/positions", nil, &positions); err != nil {
		return err
	}
	for _, p := range positions {
		fmt.Printf("  #%s %s %s %s @ %s profit %s (seen %s)\n",
			p.BrokerPositionID, p.Side, str(p.Volume), p.Symbol, str(p.OpenPrice), str(p.Profit), p.ObservedAt)
	}

	closeOrder, err := c.SendOrder("/v1/accounts/"+account.ID+"/positions/"+str(order.BrokerPositionID)+"/close",
		map[string]string{})
	if err != nil {
		return err
	}
	fmt.Printf("closed: %s at %s\n", closeOrder.State, str(closeOrder.FilledPrice))

	// Offline now rather than after the idle window. The stored password is kept.
	if err := c.call("POST", "/v1/accounts/"+account.ID+"/cool", nil, nil); err != nil {
		return err
	}

	if os.Getenv("FXAPIS_DISCONNECT") == "1" {
		// Erases the stored password for good. Only when you are done with this account.
		var result struct {
			CredentialsRemoved bool `json:"credentialsRemoved"`
		}
		if err := c.call("POST", "/v1/accounts/"+account.ID+"/disconnect", nil, &result); err != nil {
			return err
		}
		fmt.Printf("disconnected, credentials removed: %t\n", result.CredentialsRemoved)
	}
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
```

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 client [#authentication-and-the-client]

`Client.send` sets `Authorization: Bearer <key>` on every request and a JSON
content type only when there is a body. `call` decodes the `{ "data": … }` envelope and
returns an `*APIError` with the stable `Code` on failure; use `errors.As` to
inspect it. One `http.Client` is shared for the life of the process, which
keeps connections alive between calls. See [Authentication](/authentication)
and [Errors](/errors).

Nullable fields are `*string`, so a missing `filledPrice` is `nil` rather than
an empty string you might mistake for a price.

## 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 receives
**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** straight away while the broker
login runs; `BringOnline` polls `GET /v1/accounts/{id}/status` every two
seconds until `ready`, usually about ten seconds in our tests. It returns at once on a
needs-attention state — `invalid_credentials`, `trading_disabled`,
`needs_2fa`, `needs_certificate` — because those need a person, not a retry.
See [Accounts](/accounts#states).

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

`SendOrder` creates one `Idempotency-Key` per order 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"`. Never format a `float64` into a
volume; keep sizes as integer multiples of the lot step or use a decimal
package, and send the string.

| 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.                          | Returns the error. 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.                                                                                        |
| Transport error or timeout                | You did not see the answer.                                       | Retries with the same key; the server replays the first answer.                                                       |

`ORDER_UNRESOLVED` means the order may be live at the broker; resending it is
how accounts end up with two positions. We confirm it against the broker's
records automatically. More in [Idempotent MT5 orders](/guides/idempotency).

In a service, pass a `context.Context` with `http.NewRequestWithContext` so a
shutdown can stop polling — but let an order request that is already in flight
finish, or you will not see its answer.

## 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).
* [Build an MT5 trade copier](/guides/copy-trading) — detect a master's trades and mirror them.
* [Errors](/errors) and the [API reference](/api-reference).
