Docs

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.

This guide builds one Ruby script 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, json and securerandom — and there is no MetaTrader terminal on your side; it runs in our cloud. The same code drops into a Rails service object or a Sidekiq job.

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

  • Ruby 3.0 or newer. No gems.
  • 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.

If you prefer Faraday, the same structure works: one connection with the Authorization header, raise_error off, and the retry rules below instead of faraday-retry's defaults.

Set up

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

In Rails, keep the key in encrypted credentials. Never put the MT5 password in a model, a job argument or a log line — Sidekiq stores job arguments in Redis in plain text.

The complete program

Save this as trade.rb and run ruby trade.rb.

trade.rb
# 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.
require "json"
require "net/http"
require "securerandom"
require "uri"

API = URI(ENV.fetch("FXAPIS_API", "https://api.fxapis.com"))
KEY = ENV.fetch("FXAPIS_KEY")
NEEDS_ATTENTION = %w[invalid_credentials trading_disabled needs_2fa needs_certificate].freeze
# Codes that mean "nothing was placed yet — ask again with the SAME key".
RETRY_SAME_KEY = %w[SEND_FAILED IDEMPOTENCY_IN_FLIGHT ACCOUNT_NOT_READY NO_RUNTIME RATE_LIMITED].freeze

# Every failure the API reports. Match on code; the message is for people.
class ApiError < StandardError
  attr_reader :status, :code, :details

  def initialize(status, code, message, details = nil)
    super("#{status} #{code}: #{message}")
    @status = status
    @code = code
    @details = details || []
  end
end

# One connection, kept alive for every call. 90 s reads, because an order waits for the broker.
HTTP = Net::HTTP.new(API.host, API.port).tap do |http|
  http.use_ssl = API.scheme == "https"
  http.open_timeout = 10
  http.read_timeout = 90
  http.start
end

def send_request(method, path, body = nil, headers = {})
  request = Net::HTTPGenericRequest.new(method, !body.nil?, true, path)
  request["Authorization"] = "Bearer #{KEY}"
  headers.each { |name, value| request[name] = value }
  unless body.nil?
    request["Content-Type"] = "application/json" # only with a body
    request.body = JSON.generate(body)
  end
  HTTP.request(request)
end

# The error object of a failed response, or nil if the body is not ours (a proxy page).
def error_of(response)
  error = JSON.parse(response.body.to_s)["error"]
  error.is_a?(Hash) && error["code"] ? error : nil
rescue JSON::ParserError
  nil
end

def fail_with(response)
  error = error_of(response) || { "code" => "HTTP_ERROR", "message" => response.message }
  raise ApiError.new(response.code.to_i, error["code"], error["message"], error["details"])
end

# Sends a request and returns `data`, or raises ApiError.
def call(method, path, body = nil)
  response = send_request(method, path, body)
  fail_with(response) unless response.code.to_i < 300
  JSON.parse(response.body)["data"]
end

# Stores the account at fxapis. It does not log in yet.
def connect_account(login, server, password)
  call("POST", "/v1/accounts", {
    login: login, server: server, password: password,
    mode: "warm_on_demand", label: "ruby-guide"
  })
rescue ApiError => e
  raise unless e.code == "ACCOUNT_EXISTS"
  # Connected on an earlier run: find it instead of connecting it twice.
  existing = call("GET", "/v1/accounts").find { |a| a["login"] == login && a["server"] == server }
  existing or raise
end

# Starts the broker login, then polls until the account is ready to trade.
def bring_online(account_id, timeout: 120)
  call("POST", "/v1/accounts/#{account_id}/warm") # 409 NEEDS_OPERATOR / NO_SECRET raises
  deadline = Time.now + timeout
  while Time.now < deadline
    status = call("GET", "/v1/accounts/#{account_id}/status")
    return status if status["state"] == "ready"
    if NEEDS_ATTENTION.include?(status["state"])
      # Not retryable. Repeated failed logins are how brokers lock accounts.
      raise "account needs attention: #{status['state']} (#{status['detail']})"
    end
    sleep 2
  end
  raise "account not ready after #{timeout}s"
end

# After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it.
def wait_for_outcome(order_id, timeout: 300)
  deadline = Time.now + timeout
  while Time.now < deadline
    order = call("GET", "/v1/orders/#{order_id}")
    return order unless order["state"] == "unknown"
    sleep 5
  end
  raise "order #{order_id} is still unknown. Do not resend it; check it again later."
end

# Places an order (or a close) exactly once, however many times the request is retried.
def send_order(path, body, attempts: 6)
  key = SecureRandom.uuid # one key per order, reused on every retry
  attempts.times do |attempt|
    backoff = [2**attempt, 10].min
    begin
      response = send_request("POST", path, body, "Idempotency-Key" => key)
    rescue Net::ReadTimeout, Net::OpenTimeout, IOError, SystemCallError
      HTTP.finish if HTTP.started?
      HTTP.start
      sleep backoff # we never saw the answer; the same key makes asking again safe
      next
    end

    return JSON.parse(response.body)["data"] if response.code == "201"

    error = error_of(response)
    if error.nil?
      fail_with(response) if response.code.to_i < 500
      sleep backoff # not our error body: a gateway hiccup
      next
    end

    return wait_for_outcome(error["details"][0]["orderId"]) if error["code"] == "ORDER_UNRESOLVED"

    if RETRY_SAME_KEY.include?(error["code"])
      retry_after = response["retry-after"].to_i
      sleep(retry_after.positive? ? retry_after : backoff)
      next
    end
    fail_with(response) # ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
  end
  raise "gave up after #{attempts} attempts; retrying later with key #{key} is still safe"
end

login = ENV.fetch("MT5_LOGIN")
server = ENV.fetch("MT5_SERVER")
symbol = ENV.fetch("MT5_SYMBOL", "EURUSD")

account = connect_account(login, server, ENV.fetch("MT5_PASSWORD"))
id = account["id"]
puts "account #{id} (#{account['state']})"

bring_online(id)
puts "online"

begin
  order = send_order("/v1/accounts/#{id}/orders/market",
                     { symbol: symbol, side: "buy", volume: "0.01" }) # a string, never a Float
rescue ApiError => e
  raise unless e.code == "ORDER_REJECTED"
  puts "rejected by the broker: #{e.message}"
  exit 1
end
puts "order #{order['id']}: #{order['state']} at #{order['filledPrice']}"
exit 1 unless %w[filled partially_filled].include?(order["state"])

# Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
call("POST", "/v1/accounts/#{id}/reconcile")
call("GET", "/v1/accounts/#{id}/positions").each do |p|
  puts "  ##{p['brokerPositionId']} #{p['side']} #{p['volume']} #{p['symbol']} @ #{p['openPrice']} " \
       "profit #{p['profit']} (seen #{p['observedAt']})"
end

close = send_order("/v1/accounts/#{id}/positions/#{order['brokerPositionId']}/close", {})
puts "closed: #{close['state']} at #{close['filledPrice']}"

# Offline now rather than after the idle window. The stored password is kept.
call("POST", "/v1/accounts/#{id}/cool")

if ENV["FXAPIS_DISCONNECT"] == "1"
  # Erases the stored password for good. Only when you are done with this account.
  result = call("POST", "/v1/accounts/#{id}/disconnect")
  puts "disconnected, credentials removed: #{result['credentialsRemoved']}"
end

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 connection

One Net::HTTP connection is opened and kept alive; send_request adds Authorization: Bearer <key> to each request and a JSON content type only when there is a body. call returns the data of the { "data": … } envelope or raises an ApiError carrying the stable code. See Authentication and Errors.

A single Net::HTTP connection is not thread-safe. In a multi-threaded server such as Puma, or with Sidekiq concurrency, open one per thread or use a connection pool.

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 on your user record; 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; bring_online polls GET /v1/accounts/{id}/status every two seconds until ready, usually about ten seconds in our tests. A needs-attention state — invalid_credentials, trading_disabled, needs_2fa, needs_certificate — raises at once, because it needs a person rather than a retry. See Accounts.

Placing the order safely

send_order creates one Idempotency-Key per order with SecureRandom.uuid and sends the same key on every retry, so the order is placed at most once. Keys are remembered for 24 hours. In a Sidekiq job, derive the key from the job (for example "signal_#{signal_id}:user_#{user_id}") so a job Sidekiq retries reuses it too.

Volumes and prices are strings — "0.01". Compute sizes with BigDecimal and send value.to_s("F"), never a Float.

ResponseWhat it meansWhat send_order does
201The broker answered; state is filled (or partially_filled).Returns the order.
422 ORDER_REJECTEDThe broker refused it. Nothing executed.Raises. 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.
Net::ReadTimeout or a dropped connectionYou did not see the answer.Reconnects and 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 an account ends up with two positions. We confirm it against the broker's records automatically. 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 send_order 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 script 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