# Place MetaTrader 5 orders from Ruby — no MetaTrader installed

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

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



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.

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

* 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](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.

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 [#set-up]

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

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

```ruby title="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 [#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](/authentication)
and [Errors](/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 [#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](/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; `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](/accounts#states).

## Placing the order safely [#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`.

| Response                                   | What it means                                                     | What `send_order` does                                                                                     |
| ------------------------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **201**                                    | The broker answered; `state` is `filled` (or `partially_filled`). | Returns the order.                                                                                         |
| **422** `ORDER_REJECTED`                   | The 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_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.                                                                             |
| `Net::ReadTimeout` or a dropped connection | You 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](/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 `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 [#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 [#next-steps]

* [Signals and click-to-trade](/signals) — a full member-facing integration.
* [Connect MT5 accounts](/guides/connect-accounts) — the connect screen, done well.
* [Errors](/errors) and the [API reference](/api-reference).
