Docs

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.

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.

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

  • Go 1.22 or newer.
  • An fxapis API key with the accounts:write, trading:execute and trading:read scopes (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

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

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

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

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

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.

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.

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.

ResponseWhat it meansWhat SendOrder does
201The broker answered; state is filled (or partially_filled).Returns the order.
422 ORDER_REJECTEDThe 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_RUNTIMEThe account was not online in time. Nothing was sent.Retries with the same key.
409 IDEMPOTENCY_IN_FLIGHTThe first attempt with this key is still running.Retries with the same key.
502 SEND_FAILEDProven never to have reached the broker.Retries with the same key.
502 ORDER_UNRESOLVEDWe do not know yet whether it executed.Never resends. Polls GET /v1/orders/{id} until it leaves unknown.
429 RATE_LIMITEDToo many requests of this kind.Waits Retry-After, same key.
Transport error or timeoutYou 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.

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

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

On this page