# General Info

## Environments and base URLs

| Purpose | URL |
| - | - |
| REST | `https://trade-api.yellowboxmarkets.com` — all paths are prefixed `/v1`, so the effective base is `https://trade-api.yellowboxmarkets.com/v1` |
| WebSocket market streams | `wss://trade-stream.yellowboxmarkets.com/ws` (raw) and `wss://trade-stream.yellowboxmarkets.com/stream?streams=` (combined) |
| WebSocket user data stream | `wss://trade-stream.yellowboxmarkets.com/ws/<listenKey>` |

> **These two hostnames are placeholders and are subject to confirmation.** DNS has not been
> assigned yet. Keep the base URL in configuration, not in code. Nothing else in this
> documentation changes when the hostnames are finalised. This is the only place the caveat is
> stated; every other page uses these URLs as if final.

**Sandbox is not a separate host.** A key can be scoped to demo-group MT5 logins and is then
functionally a testnet key on the same endpoints. `GET /v1/accounts` reports `"demo": true` per
login.

## Request format

- `GET` and `DELETE` take parameters in the **query string**.
- `POST` and `PUT` take parameters in the query string, in an
  `application/x-www-form-urlencoded` **body**, or both.
- If a parameter appears in both, **the query string value wins**.
- Parameters may be sent in any order.
- Responses are always JSON (`Content-Type: application/json`), including error responses.
- All requests must be HTTPS. Plain HTTP is refused.

## Security types

Every endpoint declares a security type. It tells you what must accompany the request.

| Security type | `X-YBX-APIKEY` header | `timestamp` + `signature` |
| - | - | - |
| `NONE` | no | no |
| `MARKET_DATA` | yes | no |
| `USER_STREAM` | yes | no |
| `USER_DATA` | yes | **yes** |
| `TRADE` | yes | **yes** |

`TRADE` and `USER_DATA` are cryptographically identical. They differ in the **permission** the key
must hold: a read-only key can call `USER_DATA` endpoints but is refused (`-2015`) on `TRADE`
endpoints. Ask for a read-only key for any component that only needs to observe.

`MARKET_DATA` needs the key so the request can be attributed to a rate-limit bucket. It is not
account-scoped and takes no `login`.

## The `login` parameter

**This is the one deliberate departure from Binance.** On Binance an API key _is_ an account. Here
one key normally trades **many** MT5 accounts, so the account cannot be inferred from the key.

> **Every account-scoped endpoint takes a mandatory `login` parameter** — the int64 MT5 login of
> the trading account the call applies to. Omitting it is `-1102`. Sending a login the key is not
> scoped to, that does not exist, whose owner is inactive, or whose account is not active, is
> `-2022` — the same error for all of them, so a key cannot probe for the existence of logins it
> does not own.

A login that IS in scope but has trading **disabled** is a different case, and is deliberately not
hidden: reads succeed and report `tradeAllowed: false`, while anything that would trade is refused
with `-2024`. That is what makes `tradeAllowed` on
[`GET /v1/account`](/account/account-information.md) a value you can act on rather than an
error you have to provoke.

- `GET /v1/accounts` lists exactly the logins the key may trade.
- `POST /v1/batchOrders` carries `login` **per item**, so one call can fan a signal out across many
  accounts.
- The user-data stream is **per key, not per login**: one connection carries events for every login
  in scope and each event carries `L` (the login).
- Market-data endpoints and market streams take no `login`.

## SIGNED endpoint security (`TRADE` and `USER_DATA`)

SIGNED endpoints require two extra parameters:

| Name | Type | Mandatory | Description |
| - | - | - | - |
| `timestamp` | LONG | YES | Request creation time, Unix **milliseconds**. Missing is `-1102`; not an integer is `-1130`. |
| `signature` | STRING | YES | Lowercase hex HMAC-SHA256 of `totalParams`, keyed with your API secret. |
| `recvWindow` | LONG | NO | How long the request stays valid, in milliseconds. Default `5000`, maximum `60000`; outside `1`–`60000` is `-1130`. |

### `totalParams`

```
totalParams = <query string> + <request body>
```

Concatenated **verbatim, in that order, exactly as transmitted** — no separator is inserted, and
the parameters are the percent-encoded bytes you actually put on the wire. Sign last, and never
re-order or re-encode a parameter after signing.

`signature` itself is never part of `totalParams`. Send it last, either in the query string or in
the body.

Because concatenation is literal, splitting parameters between the query string and the body
produces a different `totalParams` than sending the same parameters in one place. **Send all
parameters in one place.** The mixed case is supported for Binance-client compatibility, not
recommended.

### Timing security

The server computes:

```
timestamp < serverTime + 1000  &&  serverTime - timestamp <= recvWindow
```

Both conditions must hold, otherwise `-1021`. The `+1000` ms tolerance absorbs a small clock lead;
`recvWindow` bounds how stale a request may be.

Keep `recvWindow` small. A large window widens the replay window of a request captured in flight.
`5000` is right for almost everyone. Sync your clock against `GET /v1/time`, not against a public
NTP pool, and resync if you see `-1021` clustering.

### Worked example

Use these exact values to validate your signing code.

```
apiKey  : Tq7sVn2LpZ4wKdRj9xYc3BmHfA6eQu1oGi0tNsXrWvJbEyPzMkCdHlFgAoUvQ
secret  : x9Tq2mWvJb4rZ8kLpN6yHc3sVd1fGaQeR7uI0oP5tYbXnMwKjSzCvBhDlFgA
```

**Example 1 — all parameters in the query string** (`GET /v1/account`)

```
queryString : login=100123&recvWindow=5000&timestamp=1789012345678
signature   : 13c783f085a1ef46b6a0e1df8d59c70ab50a4f8e669ffa4a2bc49998c0a9255b
```

Reproduce it:

```bash
echo -n "login=100123&recvWindow=5000&timestamp=1789012345678" \
  | openssl dgst -sha256 -hmac "x9Tq2mWvJb4rZ8kLpN6yHc3sVd1fGaQeR7uI0oP5tYbXnMwKjSzCvBhDlFgA"
```

```
SHA2-256(stdin)= 13c783f085a1ef46b6a0e1df8d59c70ab50a4f8e669ffa4a2bc49998c0a9255b
```

```bash
curl -H "X-YBX-APIKEY: Tq7sVn2LpZ4wKdRj9xYc3BmHfA6eQu1oGi0tNsXrWvJbEyPzMkCdHlFgAoUvQ" \
  "https://trade-api.yellowboxmarkets.com/v1/account?login=100123&recvWindow=5000&timestamp=1789012345678&signature=13c783f085a1ef46b6a0e1df8d59c70ab50a4f8e669ffa4a2bc49998c0a9255b"
```

**Example 2 — all parameters in the body** (`POST /v1/order`)

```
requestBody : login=100123&symbol=XAUUSD&side=BUY&type=MARKET&volume=0.10&newClientOrderId=bot-001&recvWindow=5000&timestamp=1789012345678
signature   : b7f6052aa37e7d6de935cce1c099f8103a2b0a0711b77fdf48becfc8b7d2c0e7
```

```bash
echo -n "login=100123&symbol=XAUUSD&side=BUY&type=MARKET&volume=0.10&newClientOrderId=bot-001&recvWindow=5000&timestamp=1789012345678" \
  | openssl dgst -sha256 -hmac "x9Tq2mWvJb4rZ8kLpN6yHc3sVd1fGaQeR7uI0oP5tYbXnMwKjSzCvBhDlFgA"
```

```
SHA2-256(stdin)= b7f6052aa37e7d6de935cce1c099f8103a2b0a0711b77fdf48becfc8b7d2c0e7
```

```bash
curl -X POST \
  -H "X-YBX-APIKEY: Tq7sVn2LpZ4wKdRj9xYc3BmHfA6eQu1oGi0tNsXrWvJbEyPzMkCdHlFgAoUvQ" \
  -d "login=100123&symbol=XAUUSD&side=BUY&type=MARKET&volume=0.10&newClientOrderId=bot-001&recvWindow=5000&timestamp=1789012345678&signature=b7f6052aa37e7d6de935cce1c099f8103a2b0a0711b77fdf48becfc8b7d2c0e7" \
  "https://trade-api.yellowboxmarkets.com/v1/order"
```

**Example 3 — mixed query string and body** (the same parameters as Example 2, split)

```
queryString : login=100123&symbol=XAUUSD
requestBody : side=BUY&type=MARKET&volume=0.10&newClientOrderId=bot-001&recvWindow=5000&timestamp=1789012345678
totalParams : login=100123&symbol=XAUUSDside=BUY&type=MARKET&volume=0.10&newClientOrderId=bot-001&recvWindow=5000&timestamp=1789012345678
signature   : 2cdb79a28695a12db0f60b35af89e340588d1428b97e291cb013dbff437ac392
```

Note the missing `&` between `XAUUSD` and `side` — that is the literal concatenation, and it is why
the signature differs from Example 2 even though the parameters are identical. Send everything in
one place and this case never arises.

### Python

```python
import hashlib
import hmac
import time
import urllib.parse

import requests

BASE = "https://trade-api.yellowboxmarkets.com"
API_KEY = "Tq7sVn2LpZ4wKdRj9xYc3BmHfA6eQu1oGi0tNsXrWvJbEyPzMkCdHlFgAoUvQ"
API_SECRET = b"x9Tq2mWvJb4rZ8kLpN6yHc3sVd1fGaQeR7uI0oP5tYbXnMwKjSzCvBhDlFgA"


def sign(params: dict) -> str:
    # Encode once, sign exactly those bytes, send exactly those bytes.
    query = urllib.parse.urlencode(params)
    signature = hmac.new(API_SECRET, query.encode(), hashlib.sha256).hexdigest()
    return f"{query}&signature={signature}"


def signed_post(path: str, params: dict):
    params = {**params, "recvWindow": 5000, "timestamp": int(time.time() * 1000)}
    return requests.post(
        BASE + path,
        headers={
            "X-YBX-APIKEY": API_KEY,
            "Content-Type": "application/x-www-form-urlencoded",
        },
        data=sign(params),
        timeout=10,
    ).json()


print(signed_post("/v1/order", {
    "login": 100123,
    "symbol": "XAUUSD",
    "side": "BUY",
    "type": "MARKET",
    "volume": "0.10",
    "newClientOrderId": "bot-001",
}))
```

The `sign()` helper signs the already-encoded string and sends that exact string as the body. Do
not pass a `dict` to `requests` after signing a separately-built string — the library may re-encode
in a different order and the signature will not match.

## Rate limits

Limits are enforced **per API key** across all IPs.

| Bucket | Limit | Counts |
| - | - | - |
| `REQUEST_WEIGHT` | **1200** per minute | Every request, by its endpoint weight. |
| `ORDERS` | **100** per 10 seconds | Every order-affecting request. |
| `ORDERS` | **1200** per minute | Same. |

**These numbers are tunable configuration, not contract.** They may be raised or lowered per key.
`GET /v1/exchangeInfo` returns the live values in `rateLimits[]` when you send your
`X-YBX-APIKEY` header on it (without a key it returns the defaults) — read them at start-up rather
than hard-coding these figures.

An order-affecting request is one that places, modifies, cancels or closes something:
`POST /v1/order`, `PUT /v1/order`, `DELETE /v1/order`, `PUT /v1/position`, `DELETE /v1/position`,
`DELETE /v1/allOpenPositions`, `POST /v1/batchOrders`. `DELETE /v1/allOpenPositions` counts as
**one** regardless of how many positions it closes; `POST /v1/batchOrders` counts as **one per
item**.

### Headers

Every response to a request made with an API key carries that key's current usage (a `NONE`
endpoint called without a key is not metered and carries none):

| Header | Meaning |
| - | - |
| `X-YBX-USED-WEIGHT-1M` | Request weight used in the current minute. |
| `X-YBX-ORDER-COUNT-10S` | Order-affecting requests in the current 10-second window. |
| `X-YBX-ORDER-COUNT-1M` | Order-affecting requests in the current minute. |

Track these. Back off before you hit the limit rather than after.

### Breaching a limit

| Status | Meaning |
| - | - |
| `429` | A limit was exceeded. The response carries `Retry-After` (seconds). Stop sending until it elapses. |
| `418` | You kept sending after a `429`. The key is temporarily banned. |

Bans escalate with repeated offences — from minutes to hours to a day. `Retry-After` always carries
the remaining ban. A `418` is not a transport error: retrying through it lengthens the ban.

**Use the WebSocket streams instead of polling.** Repeatedly polling `GET /v1/positions` or
`GET /v1/ticker/price` is the usual way integrators reach these limits; the tick, kline and
user-data streams carry the same information without consuming request weight.

## HTTP status codes

| Status | Meaning |
| - | - |
| `2XX` | Success. |
| `4XX` | Malformed request. The problem is on your side; do not blind-retry. |
| `429` | Rate limit breached. Back off. |
| `418` | Key temporarily banned for ignoring `429`. |
| `5XX` | Internal error. **This is a problem on our side. It is not an indication that the operation failed.** |

### The two meanings of `503`

`503` carries a body. Read `msg` before deciding what to do.

```json
{"code":-1000,"msg":"Unknown error, please check your request or try again later. The execution status is UNKNOWN and could have been a success."}
```

The request may have reached the trade server and may have executed. **Do not retry blindly.**
(This message is reserved: the current implementation reports an unknown outcome as `-5008` or
`-5009` instead — see [error-codes.md § Reading `-5008` and `-5009`](/error-codes.md#reading--5008-and--5009).
Handle all three the same way.)
Confirm first: watch for a `DEAL` event on the user data stream, or call
`GET /v1/order?origClientOrderId=` with the same client order id. If you always set
`newClientOrderId`, a retry is safe anyway — it is idempotent and returns the stored outcome.

```json
{"code":-1000,"msg":"Service is currently unavailable, please try again later."}
```

The service refused the request before doing anything. Retry with exponential backoff.

## Error response format

Every error, at any status, has the same shape:

```json
{"code":-1121,"msg":"Invalid symbol."}
```

| Field | Type | Description |
| - | - | - |
| `code` | INT | Negative error code. Stable — safe to branch on. |
| `msg` | STRING | Human-readable English text. Informational; do not parse or branch on it. |
| `mt5RetCode` | INT | **Execution errors only.** The raw MT5 trade-request return code, passed through unchanged. |

An execution error looks like this:

```json
{"code":-2018,"msg":"Insufficient margin on the trading account.","mt5RetCode":10019}
```

`mt5RetCode` is present only when the trade server actually answered. Its values are listed in
[enums.md § MT5 request return codes](/common-definition.md#mt5-request-return-codes-mt5retcode). The full error
list is in [Error Codes](/error-codes.md).

## Data conventions

### Decimals are strings

Every decimal value — prices, volumes, money — is serialised as a JSON **string**, so no precision
is lost by a JSON parser that stores numbers as IEEE doubles.

```json
{"volume":"0.10","priceOpen":"2331.20","profit":"-4.5000"}
```

Parse them into a decimal type. Never into a float. Integers (`login`, ticket ids, timestamps,
counts, digit counts) are JSON numbers.

### Volumes are lots

`volume` is always in **lots**, matching the MT5 platform. `0.10` means one tenth of a standard lot.
Per-symbol `volumeMin`, `volumeMax` and `volumeStep` from `GET /v1/exchangeInfo` are also lots, and
a volume that is not an exact multiple of `volumeStep` is rejected (`-4007`).

The lot's notional value is `volume * contractSize` in `currencyBase`.

### Prices are in symbol quote units

Prices are raw symbol prices at the symbol's `digits` precision — the same numbers the MT5 terminal
shows. Nothing is normalised across symbols.

### Symbols are case-sensitive and exact

**Use the symbol name exactly as the trade server spells it.** `BTCUSD.cent` and `BTCUSD.CENT` are
two different instruments, and a symbol that differs only in case does not exist (`-1121`).

This includes **stream names**. Unlike Binance, stream names are **not** lowercased:

```
XAUUSD@tick        correct
xauusd@tick        WRONG — no such stream
BTCUSD.cent@tick   correct
```

Enumerate the exact names with `GET /v1/exchangeInfo`. Never upper-case, lower-case or otherwise
normalise a symbol anywhere in your stack.

### Times are Unix milliseconds

Every timestamp on the wire is Unix epoch **milliseconds**, UTC.

MT5 is natively second-resolution for trade timestamps (`time`, `timeSetup`, `timeExpiration`,
`openTime`), so those fields are exact multiples of 1000 after conversion. Millisecond precision on
those fields is a unit convention, not extra resolution.

### Money is in the account's deposit currency

`balance`, `credit`, `equity`, `margin`, `freeMargin`, `profit`, `swap` and `commission` are in the
account's deposit currency. `GET /v1/account` returns it as `currency`.

> **Cent accounts.** Some MT5 groups are denominated in cents — `currency` is `USC`, and
> 100 USC = 1 USD. Values on these accounts are **not** converted to USD anywhere in this API; they
> are exactly what the trade server holds. A balance of `"100000.0000"` on a `USC` account is
> 1,000 US dollars. Always read `currency` before displaying or aggregating money, and never mix
> `USD` and `USC` values in a sum.

### Identifiers

| Identifier | Type | Notes |
| - | - | - |
| `login` | INT64 | MT5 login. |
| `orderId` | INT64 | MT5 order ticket. |
| `positionId` | INT64 | MT5 position ticket. Under hedging, one per position — a symbol can have many. |
| `dealId` | INT64 | MT5 deal ticket. The natural key of an execution. |
| `clientOrderId` | STRING | Yours. Up to 36 characters, unique per API key. The idempotency key. |

Ticket ids can exceed 2^53, so **do not parse them into a JavaScript `Number`.** Use `BigInt` or
keep them as strings.

## Versioning and backward compatibility

The `/v1` contract is frozen in the following sense:

**We may, without notice:**

- Add new fields to any JSON object in a response.
- Add new elements to the end of an array row (for example a kline row).
- Add new values to any enum (`status`, `type`, event `e`, error `code`).
- Add new endpoints, new streams and new optional request parameters.
- Change rate-limit numbers (read them from `GET /v1/exchangeInfo`).

**Your client must therefore:**

- **Ignore unknown fields** rather than fail on them.
- **Tolerate unknown enum values** — treat an unrecognised `status`, `type` or event as "something
  happened I do not model", log it, and do not crash or assume a default.
- Index array rows **from the front** and tolerate extra trailing elements.
- Never rely on field ordering in a JSON object.

**We will not, within `/v1`:**

- Remove or rename a field.
- Change the type or unit of an existing field.
- Change the meaning of an existing enum value or error code.
- Remove an endpoint or a stream.

Anything that would break those guarantees ships as `/v2`, and `/v1` keeps running alongside it for
an announced deprecation period.

Every change is recorded in [Change Log](/changelog.md).
