Place MetaTrader 5 orders from PHP — no MetaTrader installed
A complete PHP program using Guzzle that connects an MT5 account, places a market order with an idempotency key, handles SEND_FAILED and ORDER_UNRESOLVED correctly, reads positions and closes the trade — from any PHP host, with no terminal.
This guide builds one PHP 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 runs on any PHP host — shared hosting, a Laravel or Symfony app, a queue worker, a cron job — because there is no MetaTrader terminal on your side. The terminal runs in our cloud; your code makes HTTPS requests with Guzzle.
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
- PHP 8.1 or newer with the
curlandjsonextensions, and Composer. - An fxapis API key with the
accounts:write,trading:executeandtrading:readscopes (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
composer require guzzlehttp/guzzle:^7
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 itKeep the key and the password in environment variables or your framework's secret store — never in source code, never in a log line.
The complete program
Save this as trade.php and run php trade.php.
<?php
// 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.
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use Psr\Http\Message\ResponseInterface;
const NEEDS_ATTENTION = ['invalid_credentials', 'trading_disabled', 'needs_2fa', 'needs_certificate'];
// Codes that mean "nothing was placed yet — ask again with the SAME key".
const RETRY_SAME_KEY = ['SEND_FAILED', 'IDEMPOTENCY_IN_FLIGHT', 'ACCOUNT_NOT_READY', 'NO_RUNTIME', 'RATE_LIMITED'];
final class ApiError extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly string $errorCode,
string $message,
public readonly array $details = [],
) {
parent::__construct("$status $errorCode: $message");
}
}
function setting(string $name, ?string $default = null): string
{
$value = getenv($name);
if ($value === false || $value === '') {
if ($default === null) {
throw new RuntimeException("Set $name");
}
return $default;
}
return $value;
}
$http = new Client([
'base_uri' => setting('FXAPIS_API', 'https://api.fxapis.com'),
'headers' => ['Authorization' => 'Bearer ' . setting('FXAPIS_KEY')],
'http_errors' => false, // we read every status ourselves
'timeout' => 30,
]);
/** The error object of a failed response, or null if the body is not ours (a proxy page). */
function errorOf(ResponseInterface $response): ?array
{
$body = json_decode((string) $response->getBody(), true);
return is_array($body) && isset($body['error']['code']) ? $body['error'] : null;
}
function fail(ResponseInterface $response): never
{
$error = errorOf($response) ?? ['code' => 'HTTP_ERROR', 'message' => $response->getReasonPhrase()];
throw new ApiError($response->getStatusCode(), $error['code'], $error['message'], $error['details'] ?? []);
}
/** Sends a request and returns `data`, or throws ApiError. */
function api(Client $http, string $method, string $path, ?array $options = []): mixed
{
$response = $http->request($method, $path, $options ?? []);
if ($response->getStatusCode() >= 300) {
fail($response);
}
return json_decode((string) $response->getBody(), true)['data'];
}
/** Stores the account at fxapis. It does not log in yet. */
function connectAccount(Client $http, string $login, string $server, string $password): array
{
try {
return api($http, 'POST', '/v1/accounts', ['json' => [
'login' => $login,
'server' => $server,
'password' => $password,
'mode' => 'warm_on_demand',
'label' => 'php-guide',
]]);
} catch (ApiError $e) {
if ($e->errorCode !== 'ACCOUNT_EXISTS') {
throw $e;
}
// Connected on an earlier run: find it instead of connecting it twice.
foreach (api($http, 'GET', '/v1/accounts') as $account) {
if ($account['login'] === $login && $account['server'] === $server) {
return $account;
}
}
throw $e;
}
}
/** Starts the broker login, then polls until the account is ready to trade. */
function bringOnline(Client $http, string $accountId, int $timeout = 120): array
{
api($http, 'POST', "/v1/accounts/$accountId/warm"); // 409 NEEDS_OPERATOR / NO_SECRET throws
$deadline = time() + $timeout;
while (time() < $deadline) {
$status = api($http, 'GET', "/v1/accounts/$accountId/status");
if ($status['state'] === 'ready') {
return $status;
}
if (in_array($status['state'], NEEDS_ATTENTION, true)) {
// Not retryable. Repeated failed logins are how brokers lock accounts.
throw new RuntimeException("account needs attention: {$status['state']} ({$status['detail']})");
}
sleep(2);
}
throw new RuntimeException("account not ready after {$timeout}s");
}
/** After ORDER_UNRESOLVED: never resend. Poll the order until the broker's record settles it. */
function waitForOutcome(Client $http, string $orderId, int $timeout = 300): array
{
$deadline = time() + $timeout;
while (time() < $deadline) {
$order = api($http, 'GET', "/v1/orders/$orderId");
if ($order['state'] !== 'unknown') {
return $order;
}
sleep(5);
}
throw new RuntimeException("order $orderId is still unknown. Do not resend it; check it again later.");
}
/** Places an order (or a close) exactly once, however many times the request is retried. */
function sendOrder(Client $http, string $path, array|object $body, int $attempts = 6): array
{
$key = bin2hex(random_bytes(16)); // one key per order, reused on every retry
for ($attempt = 0; $attempt < $attempts; $attempt++) {
$backoff = min(2 ** $attempt, 10);
try {
$response = $http->request('POST', $path, [
'json' => $body,
'headers' => ['Idempotency-Key' => $key],
'timeout' => 90,
]);
} catch (TransferException) {
sleep($backoff); // we never saw the answer; the same key makes asking again safe
continue;
}
if ($response->getStatusCode() === 201) {
return json_decode((string) $response->getBody(), true)['data'];
}
$error = errorOf($response);
if ($error === null && $response->getStatusCode() >= 500) {
sleep($backoff); // not our error body: a gateway hiccup
continue;
}
if ($error === null) {
fail($response);
}
if ($error['code'] === 'ORDER_UNRESOLVED') {
return waitForOutcome($http, $error['details'][0]['orderId']);
}
if (in_array($error['code'], RETRY_SAME_KEY, true)) {
$retryAfter = (int) $response->getHeaderLine('retry-after');
sleep($retryAfter > 0 ? $retryAfter : $backoff);
continue;
}
fail($response); // ORDER_REJECTED, INVALID_REQUEST, QUOTA_EXCEEDED, …
}
throw new RuntimeException("gave up after $attempts attempts; retrying later with key $key is still safe");
}
$login = setting('MT5_LOGIN');
$server = setting('MT5_SERVER');
$symbol = setting('MT5_SYMBOL', 'EURUSD');
$account = connectAccount($http, $login, $server, setting('MT5_PASSWORD'));
$id = $account['id'];
echo "account $id ({$account['state']})\n";
bringOnline($http, $id);
echo "online\n";
try {
$order = sendOrder($http, "/v1/accounts/$id/orders/market", [
'symbol' => $symbol,
'side' => 'buy',
'volume' => '0.01', // a string, never a float
]);
} catch (ApiError $e) {
if ($e->errorCode === 'ORDER_REJECTED') {
echo "rejected by the broker: {$e->getMessage()}\n";
exit(1);
}
throw $e;
}
echo "order {$order['id']}: {$order['state']} at {$order['filledPrice']}\n";
if (!in_array($order['state'], ['filled', 'partially_filled'], true)) {
exit(1);
}
// Positions refresh on their own about every 15 s; reconcile (optional) refreshes them now.
api($http, 'POST', "/v1/accounts/$id/reconcile");
foreach (api($http, 'GET', "/v1/accounts/$id/positions") as $p) {
echo " #{$p['brokerPositionId']} {$p['side']} {$p['volume']} {$p['symbol']} @ {$p['openPrice']}"
. " profit {$p['profit']} (seen {$p['observedAt']})\n";
}
$close = sendOrder($http, "/v1/accounts/$id/positions/{$order['brokerPositionId']}/close", new stdClass());
echo "closed: {$close['state']} at {$close['filledPrice']}\n";
// Offline now rather than after the idle window. The stored password is kept.
api($http, 'POST', "/v1/accounts/$id/cool");
if (getenv('FXAPIS_DISCONNECT') === '1') {
// Erases the stored password for good. Only when you are done with this account.
$result = api($http, 'POST', "/v1/accounts/$id/disconnect");
echo 'disconnected, credentials removed: ' . var_export($result['credentialsRemoved'], true) . "\n";
}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.13419Authentication
The Guzzle client is created once with the base URL and the
Authorization: Bearer … header, and with http_errors off so that every
status reaches your code instead of becoming an exception — trading needs to
tell a 409 from a 502 by its code. In a framework, register one client as a
service and reuse it. Scopes and key rotation: Authentication.
Connecting the account
POST /v1/accounts stores the account and encrypts the password. It does not
log in, so it answers at once with the account in created. Store the
returned id against your user and discard the password — never write it to
your database, a queue payload or a log.
A login and server can be connected once per workspace; on a second run the
script 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 at once while the broker login
runs. bringOnline polls GET /v1/accounts/{id}/status every two seconds
until ready, which usually takes about ten seconds in our tests.
A needs-attention state — invalid_credentials, trading_disabled,
needs_2fa, needs_certificate — ends the wait immediately: it needs a person,
not a retry. warm also answers 409 for such an account, so a wrong
password is never retried against the broker. See Accounts.
In a web request, do not block on this loop. Start warm from the request and
poll from the browser through your backend, or from a queue job.
Placing the order safely
sendOrder generates one Idempotency-Key per order and sends the same
key on every retry, so the order is placed at most once whatever happens on
the network. Keys are remembered for 24 hours.
Volumes and prices are strings — '0.01'. PHP floats are doubles; if you
compute a size, use bcmath or integer lot steps 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. | 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 seconds, same key. |
| Timeout or network error | 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 an account ends up with two positions. We confirm it against the broker's
records automatically; the order then moves to what really happened. See
Idempotent MT5 orders.
If you place orders from a queue job, derive the key from the job instead of
generating it inside sendOrder — for example signal_{$signalId}:user_{$userId}
— so a job that is retried by the queue also reuses its key.
Reading positions
GET /v1/accounts/{id}/positions lists open positions with
brokerPositionId, side, volume, openPrice, currentPrice, stopLoss,
takeProfit, profit and observedAt. Rows refresh about every 15 seconds
while the account is online, and observedAt says how fresh each one is;
POST /v1/accounts/{id}/reconcile refreshes them now. Reference:
Positions on an account.
Closing the position
POST /v1/accounts/{id}/positions/{ticket}/close with an empty JSON object
closes the whole position — note new stdClass(), because Guzzle encodes an
empty PHP array as [], not {}. Send ['volume' => '0.005'] to close part.
The ticket is brokerPositionId from the order. Always close through
sendOrder: a repeated close on a hedging account opens 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 the stored password. Left
alone, a warm_on_demand account goes offline after 15 idle minutes; stops
and take profits keep working at the broker either way.
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
- Signals and click-to-trade — a full member-facing integration.
- Connect MT5 accounts — the connect screen, done well.
- Errors and the API reference.
Place MetaTrader 5 orders from Node.js and TypeScript — no MetaTrader installed
A complete TypeScript program for Node.js that connects an MT5 account, places a market order with an idempotency key, retries safely, reads positions and closes the trade using the built-in fetch — no SDK, no terminal, no Windows.
Place MetaTrader 5 orders from C# and .NET — no MetaTrader installed
A complete .NET 8 console program using HttpClient that connects an MT5 account, places a market order with an idempotency key, handles every failure correctly, reads positions and closes the trade — on Windows, Linux or macOS, with no terminal.