Code Examples
Signed request helpers, REST examples, WebSocket subscriptions, and integration patterns.
Start with the complete read-only example below, available in TypeScript, Python and Go. It discovers the account Access Point on the Gateway and then reads account details from that Access Point with signed GET requests.
Complete read-only example
Each example uses only its language's standard library, except Python, which needs the cryptography package. It loads an Ed25519 private key, sends a signed GET /v1/trading/access-point request to the Gateway to resolve the account Access Point, and then sends a signed GET /v1/configuration/accounts/{account_id}/account request to that Access Point.
Prepare your inputs
Create an API key with Trading read access to your account in Key Management. Set EXN_PRIVATE_KEY to the generated private key text (128 hex characters). If you generated your own key pair, use the PEM contents of private.pem instead: export EXN_PRIVATE_KEY="$(cat private.pem)". See Private key formats.
Set these variables in your terminal, replacing the placeholders with your own values. Use the HTTPS Gateway base URL from the DNS widget in Key Management for EXN_DISCOVERY_BASE_URL.
export EXN_API_KEY="exnsk-{your_public_api_key}"
export EXN_PRIVATE_KEY="your_private_key_text"
export EXN_ACCOUNT_ID="123456"
export EXN_DISCOVERY_BASE_URL="https://gateway.example"
Load the key and send the requests
- TypeScript
- Python
- Go
Requires Node.js 20 or newer. Save the code as trader-read-only.ts and run npx tsx trader-read-only.ts.
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto';
import type { KeyObject } from 'node:crypto';
type DiscoveryResponse = { access_point?: unknown };
type AccountDetailsResponse = Record<string, unknown>;
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}`);
return value;
}
function loadSigningKey(text: string): KeyObject {
const input = text.trim();
let key: KeyObject;
if (input.startsWith('-----BEGIN PRIVATE KEY-----')) {
key = createPrivateKey(input);
} else {
if (!/^[0-9a-fA-F]{128}$/.test(input)) {
throw new Error('Expected 128 hex characters or unencrypted PKCS#8 PEM');
}
const raw = Buffer.from(input, 'hex');
// RFC 8410 PKCS#8 DER wrapper for a 32-byte Ed25519 seed.
key = createPrivateKey({
key: Buffer.concat([
Buffer.from('302e020100300506032b657004220420', 'hex'),
raw.subarray(0, 32),
]),
format: 'der',
type: 'pkcs8',
});
const publicJwk = createPublicKey(key).export({ format: 'jwk' });
if (!Buffer.from(publicJwk.x ?? '', 'base64url').equals(raw.subarray(32))) {
throw new Error('Private key public half does not match its seed');
}
}
if (key.asymmetricKeyType !== 'ed25519') {
throw new Error('Expected an Ed25519 private key');
}
return key;
}
function baseUrl(value: string): string {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password ||
url.pathname !== '/' || url.search || url.hash) {
throw new Error('Expected an HTTPS base URL without credentials, path, query or fragment');
}
return url.origin;
}
function signedGetHeaders(
apiKey: string,
privateKey: KeyObject,
pathWithQuery: string,
): Record<string, string> {
const timestamp = Date.now();
// Serialize once: encode and sign the very same UTF-8 bytes.
const payload = Buffer.from(JSON.stringify({
api_key: apiKey,
idempotency_key: '',
timestamp,
sign_version: 1,
method: 'GET',
path: pathWithQuery,
body_hash: createHash('sha256').update(Buffer.alloc(0)).digest('base64url'),
}), 'utf8');
return {
'EXN-API-KEY': apiKey,
'EXN-IDEMPOTENCY-KEY': '',
'EXN-TIMESTAMP': String(timestamp),
'EXN-SIGN-VERSION': '1',
'EXN-DATA': payload.toString('base64url'),
'EXN-SIGN': sign(null, payload, privateKey).toString('base64url'),
};
}
async function main(): Promise<void> {
const apiKey = requiredEnv('EXN_API_KEY');
const accountId = requiredEnv('EXN_ACCOUNT_ID');
if (!/^[1-9][0-9]*$/.test(accountId) || BigInt(accountId) > 18446744073709551615n) {
throw new Error('EXN_ACCOUNT_ID must be a positive uint64 decimal string');
}
const gateway = baseUrl(requiredEnv('EXN_DISCOVERY_BASE_URL'));
const privateKey = loadSigningKey(requiredEnv('EXN_PRIVATE_KEY'));
async function getJson<T>(host: string, pathWithQuery: string): Promise<T> {
const response = await fetch(host + pathWithQuery, {
method: 'GET',
headers: signedGetHeaders(apiKey, privateKey, pathWithQuery),
// Do not forward signed headers to a redirect destination.
redirect: 'error',
signal: AbortSignal.timeout(15000),
// No body: hash the empty byte sequence, not "{}" or "null".
});
if (!response.ok) throw new Error(`Request failed: HTTP ${response.status}`);
return (await response.json()) as T;
}
// 1. Discover the Access Point on the Gateway. Cache the result in your application.
const discovery = await getJson<DiscoveryResponse>(
gateway,
`/v1/trading/access-point?account_id=${accountId}`,
);
if (typeof discovery.access_point !== 'string' || !discovery.access_point) {
throw new Error('Discovery response is missing access_point');
}
const accessPoint = baseUrl(discovery.access_point);
console.log(`Access Point: ${accessPoint}`);
// 2. Send the read-only request to the Access Point, not to the Gateway.
const account = await getJson<AccountDetailsResponse>(
accessPoint,
`/v1/configuration/accounts/${accountId}/account`,
);
console.log(JSON.stringify(account, null, 2));
}
main().catch((error: unknown) => {
// Print the message only, without credentials or signed headers.
console.error(error instanceof Error ? error.message : 'Unexpected error');
process.exitCode = 1;
});
Requires Python 3.10 or newer and the cryptography package (pip install cryptography). Save the code as trader_read_only.py and run python3 trader_read_only.py.
import base64
import hashlib
import json
import os
import re
import time
import urllib.request
from urllib.parse import urlsplit
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise ValueError(f"Set {name}")
return value
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def load_signing_key(text: str) -> Ed25519PrivateKey:
text = text.strip()
if text.startswith("-----BEGIN PRIVATE KEY-----"):
key = serialization.load_pem_private_key(text.encode(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("Expected an Ed25519 private key")
return key
if not re.fullmatch(r"[0-9a-fA-F]{128}", text):
raise ValueError("Expected 128 hex characters or unencrypted PKCS#8 PEM")
raw = bytes.fromhex(text) # 32-byte seed + 32-byte public key
key = Ed25519PrivateKey.from_private_bytes(raw[:32])
public = key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
if public != raw[32:]:
raise ValueError("Private key public half does not match its seed")
return key
def base_url(value: str) -> str:
url = urlsplit(value)
if (url.scheme != "https" or not url.hostname or url.username or url.password
or url.path not in ("", "/") or url.query or url.fragment):
raise ValueError("Expected an HTTPS base URL without credentials, path, query or fragment")
return f"https://{url.netloc}"
def signed_get_headers(api_key: str, key: Ed25519PrivateKey, path_with_query: str,
timestamp_ms: int | None = None) -> dict:
timestamp = timestamp_ms if timestamp_ms is not None else int(time.time() * 1000)
# Serialize once: encode and sign the very same UTF-8 bytes.
payload = json.dumps({
"api_key": api_key,
"idempotency_key": "",
"timestamp": timestamp,
"sign_version": 1,
"method": "GET",
"path": path_with_query,
# No body: hash the empty byte sequence, not "{}" or "null".
"body_hash": b64url(hashlib.sha256(b"").digest()),
}, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return {
"EXN-API-KEY": api_key,
"EXN-IDEMPOTENCY-KEY": "",
"EXN-TIMESTAMP": str(timestamp),
"EXN-SIGN-VERSION": "1",
"EXN-DATA": b64url(payload),
"EXN-SIGN": b64url(key.sign(payload)),
}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None # Do not forward signed headers to a redirect destination.
def get_json(host: str, path_with_query: str, api_key: str, key: Ed25519PrivateKey) -> dict:
request = urllib.request.Request(
host + path_with_query,
headers=signed_get_headers(api_key, key, path_with_query),
method="GET",
)
opener = urllib.request.build_opener(NoRedirect)
with opener.open(request, timeout=15) as response:
return json.load(response)
def main() -> None:
api_key = required_env("EXN_API_KEY")
account_id = required_env("EXN_ACCOUNT_ID")
if not re.fullmatch(r"[1-9][0-9]*", account_id) or int(account_id) > 2**64 - 1:
raise ValueError("EXN_ACCOUNT_ID must be a positive uint64 decimal string")
gateway = base_url(required_env("EXN_DISCOVERY_BASE_URL"))
key = load_signing_key(required_env("EXN_PRIVATE_KEY"))
# 1. Discover the Access Point on the Gateway. Cache the result in your application.
discovery = get_json(gateway, f"/v1/trading/access-point?account_id={account_id}", api_key, key)
access_point_value = discovery.get("access_point")
if not isinstance(access_point_value, str) or not access_point_value:
raise ValueError("Discovery response is missing access_point")
access_point = base_url(access_point_value)
print(f"Access Point: {access_point}")
# 2. Send the read-only request to the Access Point, not to the Gateway.
account = get_json(access_point, f"/v1/configuration/accounts/{account_id}/account", api_key, key)
print(json.dumps(account, indent=2))
if __name__ == "__main__":
try:
main()
except Exception as error: # Print the message only, without credentials or signed headers.
print(error)
raise SystemExit(1)
Requires Go 1.21 or newer. Save the code as main.go in an empty directory, run go mod init trader-read-only, then go run ..
package main
import (
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
var (
hexKey = regexp.MustCompile(`^[0-9a-fA-F]{128}$`)
accountID = regexp.MustCompile(`^[1-9][0-9]*$`)
)
type signedPayload struct {
APIKey string `json:"api_key"`
IdempotencyKey string `json:"idempotency_key"`
Timestamp int64 `json:"timestamp"`
SignVersion int `json:"sign_version"`
Method string `json:"method"`
Path string `json:"path"`
BodyHash string `json:"body_hash"`
}
func loadSigningKey(text string) (ed25519.PrivateKey, error) {
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "-----BEGIN PRIVATE KEY-----") {
block, _ := pem.Decode([]byte(text))
if block == nil {
return nil, errors.New("invalid PEM")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("expected an Ed25519 private key")
}
return key, nil
}
if !hexKey.MatchString(text) {
return nil, errors.New("expected 128 hex characters or unencrypted PKCS#8 PEM")
}
raw, _ := hex.DecodeString(text) // 32-byte seed + 32-byte public key
key := ed25519.NewKeyFromSeed(raw[:ed25519.SeedSize])
if hex.EncodeToString(key[ed25519.SeedSize:]) != hex.EncodeToString(raw[ed25519.SeedSize:]) {
return nil, errors.New("private key public half does not match its seed")
}
return key, nil
}
func baseURL(value string) (string, error) {
u, err := url.Parse(value)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil ||
(u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("expected an HTTPS base URL without credentials, path, query or fragment")
}
return "https://" + u.Host, nil
}
func signedGetHeaders(apiKey string, key ed25519.PrivateKey, pathWithQuery string, timestamp int64) (map[string]string, error) {
// No body: hash the empty byte sequence, not "{}" or "null".
bodyHash := sha256.Sum256(nil)
// Serialize once: encode and sign the very same UTF-8 bytes.
payload, err := json.Marshal(signedPayload{
APIKey: apiKey,
IdempotencyKey: "",
Timestamp: timestamp,
SignVersion: 1,
Method: "GET",
Path: pathWithQuery,
BodyHash: base64.RawURLEncoding.EncodeToString(bodyHash[:]),
})
if err != nil {
return nil, err
}
return map[string]string{
"EXN-API-KEY": apiKey,
"EXN-IDEMPOTENCY-KEY": "",
"EXN-TIMESTAMP": strconv.FormatInt(timestamp, 10),
"EXN-SIGN-VERSION": "1",
"EXN-DATA": base64.RawURLEncoding.EncodeToString(payload),
"EXN-SIGN": base64.RawURLEncoding.EncodeToString(ed25519.Sign(key, payload)),
}, nil
}
func getJSON(client *http.Client, host, pathWithQuery, apiKey string, key ed25519.PrivateKey) (map[string]any, error) {
headers, err := signedGetHeaders(apiKey, key, pathWithQuery, time.Now().UnixMilli())
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, host+pathWithQuery, nil)
if err != nil {
return nil, err
}
for name, value := range headers {
// Assign directly: Header.Set would canonicalize the EXN-* names.
req.Header[name] = []string{value}
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request failed: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
func run() error {
apiKey, account := os.Getenv("EXN_API_KEY"), os.Getenv("EXN_ACCOUNT_ID")
if apiKey == "" {
return errors.New("set EXN_API_KEY")
}
if id, err := strconv.ParseUint(account, 10, 64); err != nil || !accountID.MatchString(account) || id == 0 {
return errors.New("EXN_ACCOUNT_ID must be a positive uint64 decimal string")
}
gateway, err := baseURL(os.Getenv("EXN_DISCOVERY_BASE_URL"))
if err != nil {
return err
}
key, err := loadSigningKey(os.Getenv("EXN_PRIVATE_KEY"))
if err != nil {
return err
}
client := &http.Client{
Timeout: 15 * time.Second,
// Do not forward signed headers to a redirect destination.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
// 1. Discover the Access Point on the Gateway. Cache the result in your application.
discovery, err := getJSON(client, gateway, "/v1/trading/access-point?account_id="+account, apiKey, key)
if err != nil {
return err
}
value, _ := discovery["access_point"].(string)
accessPoint, err := baseURL(value)
if err != nil {
return errors.New("discovery response has no valid access_point")
}
fmt.Println("Access Point:", accessPoint)
// 2. Send the read-only request to the Access Point, not to the Gateway.
details, err := getJSON(client, accessPoint, "/v1/configuration/accounts/"+account+"/account", apiKey, key)
if err != nil {
return err
}
out, _ := json.MarshalIndent(details, "", " ")
fmt.Println(string(out))
return nil
}
func main() {
if err := run(); err != nil {
// Print the message only, without credentials or signed headers.
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Each example decodes the hex key into its 32-byte seed and checks that the public half matches; a PEM key is imported directly. The signed payload is serialized once as UTF-8 and those same bytes are signed. The GET requests have no body, so their body_hash is 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU. The signed path includes the query string exactly as sent. Every request gets a fresh timestamp and signature. See Request signing for the protocol rules.
On success, the script prints the Access Point URL and the account details returned by the Access Point. On failure, it stops and prints an error message. Discovery is rate limited: resolve the Access Point once and cache it instead of calling discovery before every request. Keep your system clock synchronized. Do not log private key material or signed headers.
Test vector
Use this synthetic vector to check your own signing code before calling the API. The key is the public test key from RFC 8032 (Test 1). It is not a credential: never register or use it, and never use it for real requests.
Ed25519 signatures are deterministic, so the same inputs always produce the same EXN-SIGN.
| Input | Value |
|---|---|
| Private key (hex, 128 characters) | 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a |
| Same key as PKCS#8 PEM | -----BEGIN PRIVATE KEY-----MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g-----END PRIVATE KEY----- |
api_key | exnsk-synthetic-example |
timestamp | 1773656070123 |
| Request | GET /v1/trading/access-point?account_id=123456, no body |
Signed payload (the exact bytes that are encoded and signed):
{"api_key":"exnsk-synthetic-example","idempotency_key":"","timestamp":1773656070123,"sign_version":1,"method":"GET","path":"/v1/trading/access-point?account_id=123456","body_hash":"47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"}
Expected headers:
EXN-API-KEY: exnsk-synthetic-example
EXN-IDEMPOTENCY-KEY:
EXN-TIMESTAMP: 1773656070123
EXN-SIGN-VERSION: 1
EXN-DATA: eyJhcGlfa2V5IjoiZXhuc2stc3ludGhldGljLWV4YW1wbGUiLCJpZGVtcG90ZW5jeV9rZXkiOiIiLCJ0aW1lc3RhbXAiOjE3NzM2NTYwNzAxMjMsInNpZ25fdmVyc2lvbiI6MSwibWV0aG9kIjoiR0VUIiwicGF0aCI6Ii92MS90cmFkaW5nL2FjY2Vzcy1wb2ludD9hY2NvdW50X2lkPTEyMzQ1NiIsImJvZHlfaGFzaCI6IjQ3REVRcGo4SEJTYS1fVEltVy01SkNldVFlUmttNU5NcEpXWkczaFN1RlUifQ
EXN-SIGN: l-H_Acia8YoTjKFMg-3fon3NOEX8TalNVA7c01DQ9dHb7pVBJC4y06qC-DbpETzh1KToiOprqEV6uzN3t69ICQ
If your EXN-DATA matches but EXN-SIGN does not, check how your code loads the private key. If EXN-DATA differs, check the payload serialization and the body hash.
For the following TypeScript examples, resolve the account Access Point. Set config.discoveryBaseUrl to the Gateway URL from the DNS widget. Send account-scoped REST requests to the returned access_point; use the same Access Point for WebSockets. The WebSocket helper below is illustrative; its URL construction is not yet verified. The imported ./lib/ helpers are illustrative dependencies and are not provided by this page.
Disclaimer
The following examples are simplified onboarding examples intended for API exploration and integration learning.
They are NOT production-ready trading bots.
Production systems should additionally implement:
-
reconnect handling
-
rate limit tracking
-
persistence
-
retry policies
-
risk management
-
monitoring
-
security hardening
TypeScript: open position and wait for transaction event
import { loadExampleConfig } from './lib/config.js';
import { createIdempotencyKey, loadSignedAuthFromEnv, requestJson, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeTransactions, waitForJsonMessage } from './lib/ws.js';
type AckResponse = {
status: string;
operation_id?: string;
client_request_id?: string;
};
type TransactionEvent = {
event_type?: string;
client_request_id?: string;
operation_id?: string;
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/events`,
auth,
});
closeOnSigint(ws);
subscribeTransactions(ws, 'open-position-transactions');
const idempotencyKey = createIdempotencyKey('open-position');
const ack = await requestJson<AckResponse>({
baseUrl: accessPoint.access_point,
method: 'POST',
path: `/v1/trading/accounts/${config.accountId}/positions`,
auth,
idempotencyKey,
body: {
instrument: config.instrument,
side: config.side,
volume: config.volume,
},
});
console.log('ACK:');
console.log(JSON.stringify(ack, null, 2));
const transactionEvent = await waitForJsonMessage<TransactionEvent>(
ws,
(message) =>
message.event_type === 'transaction_event' &&
(message.client_request_id === idempotencyKey || message.operation_id === ack.operation_id),
config.timeoutMs,
);
console.log('Matched transaction_event:');
console.log(JSON.stringify(transactionEvent, null, 2));
ws.close();
TypeScript: place pending order
import { loadExampleConfig, requireEnv } from './lib/config.js';
import { createIdempotencyKey, loadSignedAuthFromEnv, requestJson, resolveTradingAccessPoint } from './lib/http.js';
type AckResponse = Record<string, unknown>;
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const price = requireEnv('EXNESS_LIMIT_PRICE', config.limitPrice);
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ack = await requestJson<AckResponse>({
baseUrl: accessPoint.access_point,
method: 'POST',
path: `/v1/trading/accounts/${config.accountId}/orders`,
auth,
idempotencyKey: createIdempotencyKey('pending-order'),
body: {
type: 'limit',
instrument: config.instrument,
side: config.side,
volume: config.volume,
price,
},
});
console.log('Pending order ACK:');
console.log(JSON.stringify(ack, null, 2));
TypeScript: helper for signed WebSocket requests
The URL construction in this helper still requires verification of supported schemes, ports, trailing slashes and base-path prefixes. Do not treat it as a validated production URL builder. The connection URL and signed path must agree.
import { randomUUID } from 'node:crypto';
import WebSocket from 'ws';
import { buildSignedHeaders } from './signing.js';
import type { SignedAuth } from './http.js';
export type WsJson = Record<string, unknown>;
export function toWsBaseUrl(baseUrl: string): string {
return baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
}
export async function connectSignedWebSocket({
baseUrl,
path,
auth,
}: {
baseUrl: string;
path: string;
auth: SignedAuth;
}): Promise<WebSocket> {
const headers = await buildSignedHeaders({
apiKey: auth.apiKey,
privateKey: auth.privateKey,
method: 'GET',
pathWithQuery: path,
body: '',
idempotencyKey: '',
});
const ws = new WebSocket(`${toWsBaseUrl(baseUrl)}${path}`, 'exness-ws-protocol', {
headers,
handshakeTimeout: 10000,
perMessageDeflate: false,
});
await new Promise<void>((resolve, reject) => {
ws.once('open', () => resolve());
ws.once('error', (error: Error) => reject(error));
});
return ws;
}
export function sendJson(ws: WebSocket, payload: object): void {
ws.send(JSON.stringify(payload));
}
export function closeOnSigint(ws: WebSocket): void {
process.on('SIGINT', () => {
ws.close();
process.exit(0);
});
}
export function logParsedMessages(ws: WebSocket, label = 'ws'): void {
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
try {
console.log(`[${label}]`, JSON.stringify(JSON.parse(text)));
} catch {
console.log(`[${label}]`, text);
}
});
}
export function subscribeTransactions(ws: WebSocket, requestId: string = randomUUID()): string {
sendJson(ws, { id: requestId, subscribe: { event: 'transactions' } });
return requestId;
}
export function subscribeAccountState(ws: WebSocket, requestId: string = randomUUID()): string {
sendJson(ws, { id: requestId, subscribe: { event: 'account_state' } });
return requestId;
}
export function subscribeInstruments(ws: WebSocket, instruments: string[] = ['all'], requestId: string = randomUUID()): string {
sendJson(ws, { id: requestId, subscribe: { event: 'instruments', instruments } });
return requestId;
}
export function subscribeHmr(ws: WebSocket, instruments: string[], requestId: string = randomUUID()): string {
sendJson(ws, { id: requestId, subscribe: { event: 'hmr', instruments } });
return requestId;
}
export function subscribeTicks(ws: WebSocket, instruments: string[], requestId: string = randomUUID()): string {
sendJson(ws, { id: requestId, subscribe: { event: 'ticks', instruments } });
return requestId;
}
export function waitForJsonMessage<T extends WsJson>(
ws: WebSocket,
predicate: (message: T) => boolean,
timeoutMs = 30000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timed out after ${timeoutMs} ms`));
}, timeoutMs);
const onMessage = (raw: WebSocket.RawData) => {
const text = raw.toString();
let parsed: T;
try {
parsed = JSON.parse(text) as T;
} catch {
return;
}
if (!predicate(parsed)) {
return;
}
cleanup();
resolve(parsed);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
function cleanup() {
clearTimeout(timer);
ws.off('message', onMessage);
ws.off('error', onError);
}
ws.on('message', onMessage);
ws.on('error', onError);
});
}
TypeScript: subscribe to live XAUUSD ticks
import type WebSocket from 'ws';
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeTicks } from './lib/ws.js';
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/ticks`,
auth,
});
closeOnSigint(ws);
subscribeTicks(ws, [config.instrument], 'ticks-live');
console.log(`Subscribed to live ticks for ${config.instrument}`);
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
try {
const message = JSON.parse(text);
console.log(JSON.stringify(message, null, 2));
} catch {
console.log(text);
}
});
TypeScript: ticks bot
import type WebSocket from 'ws';
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeTicks } from './lib/ws.js';
type TickMessage = {
instrument?: string;
bid?: number | string;
ask?: number | string;
timestamp?: string;
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/ticks`,
auth,
});
closeOnSigint(ws);
subscribeTicks(ws, [config.instrument], 'ticks-bot');
let previousMid: number | null = null;
console.log(`Ticks bot started for ${config.instrument}`);
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
let message: TickMessage;
try {
message = JSON.parse(text) as TickMessage;
} catch {
return;
}
if (!message.instrument || message.bid === undefined || message.ask === undefined) {
return;
}
const bid = Number(message.bid);
const ask = Number(message.ask);
const mid = (bid + ask) / 2;
const spread = ask - bid;
const direction =
previousMid === null ? 'flat' :
mid > previousMid ? 'up' :
mid < previousMid ? 'down' :
'flat';
previousMid = mid;
console.log(JSON.stringify({
instrument: message.instrument,
timestamp: message.timestamp,
bid,
ask,
mid,
spread,
direction,
}));
});
TypeScript: account equity monitor
import type WebSocket from 'ws';
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeAccountState } from './lib/ws.js';
type AccountStateEvent = {
event_type?: string;
event_time?: string;
payload?: {
account_state?: {
balance?: string;
equity?: string;
used_margin?: string;
};
};
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/events`,
auth,
});
closeOnSigint(ws);
subscribeAccountState(ws, 'equity-monitor');
console.log('Account equity monitor started');
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
let message: AccountStateEvent;
try {
message = JSON.parse(text) as AccountStateEvent;
} catch {
return;
}
if (message.event_type !== 'account_state_event') {
return;
}
const state = message.payload?.account_state;
console.log(JSON.stringify({
event_time: message.event_time,
balance: state?.balance,
equity: state?.equity,
used_margin: state?.used_margin,
}));
});
TypeScript: get available instruments and subscribe to updates
import type WebSocket from 'ws';
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, requestJson, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeInstruments } from './lib/ws.js';
type InstrumentListResponse = {
instruments?: string[];
};
type InstrumentEvent = {
event_type?: string;
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const instrumentList = await requestJson<InstrumentListResponse>({
baseUrl: accessPoint.access_point,
method: 'GET',
path: `/v1/configuration/accounts/${config.accountId}/instruments`,
auth,
});
console.log('Available instruments:');
console.log(JSON.stringify({
count: instrumentList.instruments?.length ?? 0,
preview: instrumentList.instruments?.slice(0, 20) ?? [],
}, null, 2));
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/events`,
auth,
});
closeOnSigint(ws);
subscribeInstruments(ws, ['all'], 'instrument-updates');
console.log('Subscribed to instrument updates');
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
let message: InstrumentEvent;
try {
message = JSON.parse(text) as InstrumentEvent;
} catch {
return;
}
if (message.event_type === 'instrument_event') {
console.log(JSON.stringify(message, null, 2));
}
});
TypeScript: Subscribe to HMR
import type WebSocket from 'ws';
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeHmr } from './lib/ws.js';
type HmrEvent = {
event_type?: string;
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const instruments = (process.env.EXNESS_HMR_INSTRUMENTS ?? config.instrument)
.split(',')
.map((item: string) => item.trim())
.filter(Boolean);
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/events`,
auth,
});
closeOnSigint(ws);
subscribeHmr(ws, instruments, 'hmr-subscribe');
console.log(`Subscribed to HMR for: ${instruments.join(', ')}`);
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
let message: HmrEvent;
try {
message = JSON.parse(text) as HmrEvent;
} catch {
return;
}
if (message.event_type === 'hmr_snapshot' || message.event_type === 'hmr_update') {
console.log(JSON.stringify(message, null, 2));
}
});
TypeScript: Subscribe to Transactions
import type WebSocket from 'ws';
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeTransactions } from './lib/ws.js';
type TradingEvent = {
event_type?: string;
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/events`,
auth,
});
closeOnSigint(ws);
subscribeTransactions(ws, 'transactions-subscribe');
console.log('Subscribed to transactions');
ws.on('message', (raw: WebSocket.RawData) => {
const text = raw.toString();
let message: TradingEvent;
try {
message = JSON.parse(text) as TradingEvent;
} catch {
return;
}
if (message.event_type === 'trading_state_snapshot' || message.event_type === 'transaction_event') {
console.log(JSON.stringify(message, null, 2));
}
});
TypeScript: Correlate ACK and transaction_event
import { loadExampleConfig, requireEnv } from './lib/config.js';
import { createIdempotencyKey, loadSignedAuthFromEnv, requestJson, resolveTradingAccessPoint } from './lib/http.js';
import { closeOnSigint, connectSignedWebSocket, subscribeTransactions, waitForJsonMessage } from './lib/ws.js';
type AckResponse = {
operation_id?: string;
client_request_id?: string;
};
type TransactionEvent = {
event_type?: string;
client_request_id?: string;
operation_id?: string;
status?: string;
error_code?: number;
error_message?: string;
};
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const price = requireEnv('EXNESS_LIMIT_PRICE', config.limitPrice);
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const ws = await connectSignedWebSocket({
baseUrl: accessPoint.access_point,
path: `/v1/server-events/accounts/${config.accountId}/ws/events`,
auth,
});
closeOnSigint(ws);
subscribeTransactions(ws, 'ack-correlation');
const idempotencyKey = createIdempotencyKey('ack-correlation');
const ack = await requestJson<AckResponse>({
baseUrl: accessPoint.access_point,
method: 'POST',
path: `/v1/trading/accounts/${config.accountId}/orders`,
auth,
idempotencyKey,
body: {
type: 'limit',
instrument: config.instrument,
side: config.side,
volume: config.volume,
price,
},
});
console.log('ACK:');
console.log(JSON.stringify(ack, null, 2));
const transactionEvent = await waitForJsonMessage<TransactionEvent>(
ws,
(message) =>
message.event_type === 'transaction_event' &&
(message.client_request_id === idempotencyKey || message.operation_id === ack.operation_id),
config.timeoutMs,
);
console.log('Correlation result:');
console.log(JSON.stringify({
ack_operation_id: ack.operation_id,
ack_client_request_id: ack.client_request_id,
matched_operation_id: transactionEvent.operation_id,
matched_client_request_id: transactionEvent.client_request_id,
matched_status: transactionEvent.status,
matched_error_code: transactionEvent.error_code,
matched_error_message: transactionEvent.error_message,
}, null, 2));
ws.close();
TypeScript: Poll Operation Status
import { loadExampleConfig, requireEnv } from './lib/config.js';
import { createIdempotencyKey, loadSignedAuthFromEnv, requestJson, resolveTradingAccessPoint } from './lib/http.js';
type AckResponse = {
operation_id?: string;
};
type OperationStatusResponse = {
status: string;
};
function isTerminalStatus(status: string): boolean {
return status === 'confirmed' || status === 'rejected' || status === 'failed';
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
let operationId = process.env.EXNESS_OPERATION_ID;
if (!operationId) {
const price = requireEnv('EXNESS_LIMIT_PRICE', config.limitPrice);
const ack = await requestJson<AckResponse>({
baseUrl: accessPoint.access_point,
method: 'POST',
path: `/v1/trading/accounts/${config.accountId}/orders`,
auth,
idempotencyKey: createIdempotencyKey('poll-operation'),
body: {
type: 'limit',
instrument: config.instrument,
side: config.side,
volume: config.volume,
price,
},
});
operationId = ack.operation_id;
console.log('Created operation from new pending order ACK:');
console.log(JSON.stringify(ack, null, 2));
}
if (!operationId) {
throw new Error('Operation ID is missing');
}
for (;;) {
const status = await requestJson<OperationStatusResponse & Record<string, unknown>>({
baseUrl: accessPoint.access_point,
method: 'GET',
path: `/v1/trading/accounts/${config.accountId}/operations/${operationId}`,
auth,
});
console.log(JSON.stringify(status, null, 2));
if (isTerminalStatus(status.status)) {
break;
}
await sleep(1000);
}
TypeScript: Get Rate Limits
import { loadExampleConfig } from './lib/config.js';
import { loadSignedAuthFromEnv, requestJson, resolveTradingAccessPoint } from './lib/http.js';
type LimitsResponse = Record<string, unknown>;
const config = loadExampleConfig();
const auth = await loadSignedAuthFromEnv();
const accessPoint = await resolveTradingAccessPoint({
accountId: config.accountId,
discoveryBaseUrl: config.discoveryBaseUrl,
auth,
});
const limits = await requestJson<LimitsResponse>({
baseUrl: accessPoint.access_point,
method: 'GET',
path: `/v1/configuration/accounts/${config.accountId}/limits`,
auth,
});
console.log('API limits:');
console.log(JSON.stringify(limits, null, 2));