NodeJS

API Services

Detailed API methods for each service in Node.js SDK

1. Authentication (Auth)

Access directly on auth.

Authenticate with OTP

const token = await auth.authenticate('YOUR_OTP');
console.log(`Token expires at (epoch seconds): ${token.expiresAt}`);

authenticate(otp?, transactionId?): otp and transactionId are mutually exclusive, pass only one. For push-approval Smart OTP, pass the transactionId (from requestOtp) after the user has approved on the device:

const token = await auth.authenticate(undefined, 'YOUR_TRANSACTION_ID');

Request OTP

// Loosely typed: Record<string, unknown>
await auth.requestOtp();

// Typed: OTPResponse { message, transactionId? }
const otp = await auth.requestOtpTyped();
if (otp.transactionId) {
  // only present for Smart OTP
  await auth.ensureAuthenticated(undefined, otp.transactionId);
}

transactionId is only present for Smart OTP accounts. With Smart OTP you can also read the code from the app and pass it to authenticate('YOUR_OTP'), with no requestOtp() call first.

Refresh token

const token = await auth.refresh();

Ensure authenticated

await auth.ensureAuthenticated('YOUR_OTP');

ensureAuthenticated(otp?, transactionId?, pollIntervalMs?, pollMaxRetries?): refreshes if a usable refresh token exists, otherwise uses the OTP or transactionId. A token with an unknown expiry is treated as expired, and a token counts as expired 30 seconds early. When the session cannot be refreshed and no OTP is given, the SDK throws ReauthenticationRequired (see Error Handling).

Token state

const token = auth.getToken();
auth.setToken(token);

auth.isTokenExpired;   // boolean
auth.hasRefreshToken;  // boolean
auth.canRefresh;       // is the refresh token still usable?

auth.token, auth.accessToken, auth.isTokenExpired, auth.hasRefreshToken and auth.canRefresh are getters. setToken accepts a Token or a TokenInit. For Token details see Models.


2. Account (AccountService)

Access via trading.account.

Get account list

const accounts = await trading.account.getAccountInfo();

for (const acc of accounts) {
  console.log(`${acc.accountNo} (${acc.accountType ?? 'unknown'})`);
}

Returns: Account[]. Each Account has accountNo: string and accountType?: string. accountType is undefined when the server omits it, and it is the server's raw string when the value is not in AccountType.


3. Market Data (MarketDataService)

Access via data.marketData. No OTP required.

3.1. OHLC Data (candlesticks)

Intraday:

MethodTimeframe
getOhlc1Minute(symbol)1 minute
getOhlc3Minute(symbol)3 minutes
getOhlc5Minute(symbol)5 minutes
getOhlc15Minute(symbol)15 minutes
getOhlc1Hour(symbol)1 hour
const ohlc = await data.marketData.getOhlc1Minute('SSI');
for (const candle of ohlc) {
  console.log(`${candle.tradingDate}: O=${candle.openPrice} H=${candle.highPrice} L=${candle.lowPrice} C=${candle.closePrice} V=${candle.volume}`);
}

Historical (with date range + pagination):

MethodTimeframe
getOhlc1MinuteHistorical(symbol, from, to, page?, size?)1 minute
getOhlc3MinuteHistorical(...)3 minutes
getOhlc5MinuteHistorical(...)5 minutes
getOhlc15MinuteHistorical(...)15 minutes
getOhlc1HourHistorical(...)1 hour
getOhlc1DayHistorical(...)1 day

getOhlc1WeekHistorical and getOhlc1MonthHistorical are deprecated and always throw ValidationError (the server rejects the 1-week/1-month timeframes with code 400210). If you need weekly or monthly candles, aggregate daily candles on the client.

const ohlc = await data.marketData.getOhlc1DayHistorical(
  'SSI', '2026/03/27', '2026/04/22', 1, 100,
);

Historical parameters:

ParameterTypeDefaultDescription
symbolstring(required)Security symbol
fromstring(required)Start date (yyyy/MM/dd)
tostring(required)End date (yyyy/MM/dd)
pagenumber1Page number. Less than 1 is refused locally
sizenumber1000Records per page. Less than 1 is refused locally

If from/to hold a date only (no time), the SDK completes from with 00:00:00 and to with 23:59:59.

Returns: OHLCData[]

Fetch every page:

MethodDescription
iterOhlc(symbol, timeframe, from?, to?, size?)Async generator that walks every candle across all pages. Stops at an empty or short page. Candles arrive in server order (newest first)
getOhlcAll(symbol, timeframe, from?, to?, size?, ascending = false)Collects every candle into one OHLCData[]. Set ascending = true to reverse it to oldest first

timeframe accepts only the values in ALLOWED_TIMEFRAMES (1d, 1m, 3m, 5m, 15m, 1h). Without from/to the SDK uses the start and end of today. The downloadOhlc* methods are not implemented and throw.

import { Timeframe } from '@ssi.developer/ssi-sdk';

for await (const bar of data.marketData.iterOhlc('SSI', Timeframe.DAY_1, '2026/03/01', '2026/03/31')) {
  console.log(bar.tradingDate, bar.closePrice);
}

const all = await data.marketData.getOhlcAll('SSI', Timeframe.DAY_1, '2026/03/01', '2026/03/31', 1000, true);

3.2. Market Indexes

// All indexes
const indexes = await data.marketData.getIndexes();

// By board
import { Board } from '@ssi.developer/ssi-sdk';
const indexes = await data.marketData.getIndexesByBoard(Board.HOSE);

for (const idx of indexes) {
  console.log(`${idx.index} - ${idx.indexName}`);
}

REST accepts only the HOSE, HNX and UPCOM boards. Board.DERIVATIVES is for streaming and master data only, and is rejected here.

Returns: MarketIndexes[]

3.3. Index Summary

// Current summary
const summary = await data.marketData.getIndexSummary('VNINDEX');

// Historical summary
const summary = await data.marketData.getIndexSummaryHistorical('VNINDEX', '2026/01/15');

// Board summary
const summary = await data.marketData.getBoardSummary(Board.HOSE);

// Historical board summary
const summary = await data.marketData.getBoardSummaryHistorical(Board.HOSE, '2026/01/15');

Returns: MarketIndexSummary | undefined (undefined when the server has no data).

3.4. Securities Info

// One symbol
const info = await data.marketData.getSecuritiesInfo('SSI');

// By index
const securities = await data.marketData.getSecuritiesInfoByIndex('VN30');

// By board
const securities = await data.marketData.getSecuritiesInfoByBoard(Board.HOSE);

Returns: getSecuritiesInfo returns SecuritiesInfo | undefined. The ByIndex/ByBoard variants return SecuritiesInfo[].

3.5. Securities Summary

// Current summary
const summary = await data.marketData.getSecuritiesSummary('SSI');

// Historical summary
const summary = await data.marketData.getSecuritiesSummaryHistorical(
  'SSI', '2026/03/01', '2026/03/31',
);

// By index
const summary = await data.marketData.getSecuritiesSummaryByIndex('VN30');

// By index, historical
const summary = await data.marketData.getSecuritiesSummaryByIndexHistorical(
  'VN30', '2026/03/01', '2026/03/31',
);

Returns: SecuritiesSummary[]. The SDK fetches every page.

3.6. Master Data (ceiling, floor, reference prices)

// Today
const master = await data.marketData.getMasterData();

// Over a date range
const history = await data.marketData.getMasterDataHistorical('2026/03/01', '2026/03/31');

Returns: MasterData[] (each item has symbol, tradingDate, ceiling, floor, refPrice and an optional board). The SDK fetches every page.

3.7. Corporate Actions

Two methods, split by time (GET /api/v3/data/corporateActions):

MethodParametersReturnsDescription
getCorporateActions(symbol?, language?)symbol?: string, language = 'vn'Promise<CorporateAction[]>Today's corporate actions
getCorporateActionsHistorical(from, to, symbol?, language?)from: string, to: string, symbol?: string, language = 'vn'Promise<CorporateAction[]>Corporate actions in a past date range
// Today (every symbol, or one symbol)
const today = await data.marketData.getCorporateActions();
const one = await data.marketData.getCorporateActions('SSI', 'en');

// Past: a range of at most 5 days
const past = await data.marketData.getCorporateActionsHistorical(
  '2026/10/08',
  '2026/10/12',
  undefined, // undefined = every symbol
  'vn',      // 'vn' (default) or 'en'
);
for (const e of past) {
  console.log(e.symbol, e.eventTitle, e.exrightDate, e.recordDate, e.issueDate, e.exchange, e.ratio);
}

Returns: CorporateAction[] (each item has symbol, eventTitle, exrightDate, recordDate, issueDate, exchange, ratio). All fields are strings as the server sends them, see Models. The SDK fetches every page.

The SDK checks these on the client before any network call and throws ValidationError on a failure:

  • from and to use the YYYY/MM/DD format, from is not after to, and the two dates are at most 5 days apart. getCorporateActions() uses your machine's current date for both ends.
  • symbol (if given) is a single alphanumeric symbol of at most 20 characters. Leave it out to get every symbol.
  • language is 'vn' or 'en' (case-insensitive).

Not yet run against the live server

These two methods are covered by unit tests with a mocked HTTP adapter and have not been run against the live server yet. Check the results yourself before relying on them in production logic.


4. Portfolio (PortfolioService)

Access via trading.portfolio.

4.1. Account Balance

// Equity balance
const balance = await trading.portfolio.getEquityBalance('YOUR_ACCOUNT_NO');

// Derivative balance
const derBalance = await trading.portfolio.getDerivativeBalance('YOUR_DERIVATIVE_ACCOUNT_NO');

Both return undefined when the server has no data.

4.2. Positions

// Equity positions
const positions = await trading.portfolio.getEquityPositions('YOUR_ACCOUNT_NO');
for (const pos of positions ?? []) {
  console.log(`${pos.symbol}: ${pos.quantity} shares | Cost price: ${pos.costPrice}`);
}

// All derivative positions
const derPositions = await trading.portfolio.getDerivativePositions('YOUR_DERIVATIVE_ACCOUNT_NO');

// Open positions only
const openPos = await trading.portfolio.getOpenDerivativePositions('YOUR_DERIVATIVE_ACCOUNT_NO');

// Closed positions only
const closedPos = await trading.portfolio.getClosedDerivativePositions('YOUR_DERIVATIVE_ACCOUNT_NO');

getEquityPositions and getDerivativePositions return undefined when there is no data. getOpenDerivativePositions and getClosedDerivativePositions return an empty array [] when the server omits the list.

4.3. Order Book

// Today's orders
const orders = await trading.portfolio.getTodayOrders('YOUR_ACCOUNT_NO');
for (const order of orders) {
  console.log(`${order.orderId}: ${order.symbol} ${order.side} ${order.quantity}@${order.price} - ${order.status}`);
}

// Historical orders
const orders = await trading.portfolio.getHistoricalOrders('YOUR_ACCOUNT_NO', '2026/01/01', '2026/01/31');

4.4. PPMMR

// Equity PPMMR
const ppmmr = await trading.portfolio.getEquityPpmmr('YOUR_ACCOUNT_NO');

// Derivative PPMMR
const derPPMMR = await trading.portfolio.getDerivativePpmmr('YOUR_DERIVATIVE_ACCOUNT_NO');

Portfolio Method Summary

GroupMethodReturns
BalancegetEquityBalance(accountNo)EquityAccountBalance | undefined
getDerivativeBalance(accountNo)DerivativeAccountBalance | undefined
PositionsgetEquityPositions(accountNo)EquityPosition[] | undefined
getDerivativePositions(accountNo)AllDerivativePosition | undefined
getOpenDerivativePositions(accountNo)DerivativePosition[] ([] if none)
getClosedDerivativePositions(accountNo)DerivativePosition[] ([] if none)
Order bookgetTodayOrders(accountNo)Order[]
getHistoricalOrders(accountNo, from, to)Order[]
PPMMRgetEquityPpmmr(accountNo)EquityPPMMR | undefined
getDerivativePpmmr(accountNo)DerivativePPMMR | undefined

5. Trading (TradingService)

Access via trading.trading.

Prices (price and the other price parameters) accept PriceLike (number or string). Prefer a plain decimal string, with no exponent and no thousands separator. Every order-placing method has an optional trailing clientRequestId? (at most 20 characters), which is your idempotency key.

5.1. Place Orders

import { OrderSide, OrderType } from '@ssi.developer/ssi-sdk';

// Limit order (LO)
const result = await trading.trading.placeLimitOrder('YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100, 66000);

// Market order (MTL)
const result = await trading.trading.placeMarketOrder('YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100);

// ATO order (opening)
const result = await trading.trading.placeAtoOrder('YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100);

// ATC order (closing)
const result = await trading.trading.placeAtcOrder('YOUR_ACCOUNT_NO', 'SSI', OrderSide.SELL, 100);

// Custom order type
const result = await trading.trading.placeOrder(
  'YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100, 66000, OrderType.LO,
);

console.log(`Order ID: ${result.orderId}, Status: ${result.status}`);

5.2. Modify Orders

You can modify only price or quantity (not both at once).

// Modify price by clientRequestId
const result = await trading.trading.modifyOrderPrice('YOUR_ACCOUNT_NO', 'YOUR_CLIENT_REQUEST_ID', 68000);

// Modify price by orderId
const result = await trading.trading.modifyOrderPriceByOrderId('YOUR_ACCOUNT_NO', 'YOUR_ORDER_ID', 68000);

// Modify quantity by clientRequestId
const result = await trading.trading.modifyOrderQuantity('YOUR_ACCOUNT_NO', 'YOUR_CLIENT_REQUEST_ID', 200);

// Modify quantity by orderId
const result = await trading.trading.modifyOrderQuantityByOrderId('YOUR_ACCOUNT_NO', 'YOUR_ORDER_ID', 200);

modifyOrderPriceById and modifyOrderQuantityById are old deprecated aliases of modifyOrderPriceByOrderId and modifyOrderQuantityByOrderId.

5.3. Cancel Orders

// Cancel by clientRequestId
const result = await trading.trading.cancelOrder('YOUR_ACCOUNT_NO', 'YOUR_CLIENT_REQUEST_ID');

// Cancel by orderId
const result = await trading.trading.cancelOrderById('YOUR_ACCOUNT_NO', 'YOUR_ORDER_ID');

5.4. Max Buy/Sell

// At a specific price
const maxBS = await trading.trading.getMaxBuySell('YOUR_ACCOUNT_NO', 'SSI', 66000);
console.log(`Max buy: ${maxBS.maxBuyQuantity}, Max sell: ${maxBS.maxSellQuantity}`);

// At market price
const maxBS = await trading.trading.getMaxBuySellAtMarketPrice('YOUR_ACCOUNT_NO', 'SSI');

Derivative accounts receive only the two quantities maxBuyQuantity and maxSellQuantity. See Models for MaxBuySellResponse.

5.5. Conditional Orders (Flexible Conditional Orders - FCO)

The SDK supports 7 flexible conditional order (FCO) types (GTD, Stop, Stop Limit, Trailing Stop, Trailing Stop Limit, OCO, Bull Bear), FCO list queries, the FCO order book, FCO status history and FCO cancellation.

Every placeFco* method and cancelFco takes an extra trailing parameter code? (an optional OTP), which is sent only when set. FCO dates use the format YYYY/MM/DD HH:MM:SS, fromDate <= toDate, and the window is at most 31 days.

import { FCOOperator, OrderSide, OrderType, fromBeginningOfDay, fromEndOfDay } from '@ssi.developer/ssi-sdk';

const fromDate = fromBeginningOfDay(); // "YYYY/MM/DD 00:00:00"
const toDate = fromEndOfDay();         // "YYYY/MM/DD 23:59:59"

// GTD order (Good Till Date)
const gtd = await trading.trading.placeFcoGtd(
  'YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100, 25000, 500, fromDate, toDate,
);
console.log(`FCO ID: ${gtd.fcoId}`);

// Stop Market order
const stop = await trading.trading.placeFcoStop(
  'YOUR_ACCOUNT_NO', 'SSI', OrderSide.SELL, 100, 24000, FCOOperator.LESSER_OR_EQUAL, fromDate, toDate,
);

// Stop Limit order
const stopLimit = await trading.trading.placeFcoStopLimit(
  'YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100, 25500, 500, 25000, FCOOperator.GREATER_OR_EQUAL, fromDate, toDate,
);

// Trailing Stop order
const trailing = await trading.trading.placeFcoTrailingStop(
  'YOUR_ACCOUNT_NO', 'SSI', OrderSide.BUY, 100, 26000, 1000, fromDate, toDate,
);

// OCO order (One-Cancels-the-Other)
const oco = await trading.trading.placeFcoOco(
  'YOUR_ACCOUNT_NO', 'SSI', OrderSide.SELL, 100, 30000, 24000, OrderType.MTL, OrderType.MTL, 500, 500, fromDate, toDate,
);

// List FCO orders by account
const fcoList = await trading.trading.getFcoByAccountNo('YOUR_ACCOUNT_NO', 1, 10);
console.log(`FCO items: ${fcoList.fcoList.length}`);

// Status history of one FCO (empty array if none yet)
const history = await trading.trading.getFcoStatusHistory(gtd.fcoId);

// Cancel an FCO
await trading.trading.cancelFco(gtd.fcoId);
MethodDescription
placeFcoGtd(accountNo, symbol, side, quantity, price, priceSlip, fromDate, toDate, code?)Place a GTD (Good Till Date) order
placeFcoStop(accountNo, symbol, side, quantity, stopPrice, operator, fromDate, toDate, code?)Place a Stop Market order
placeFcoStopLimit(accountNo, symbol, side, quantity, price, priceSlip, stopPrice, operator, fromDate, toDate, code?)Place a Stop Limit order
placeFcoTrailingStop(accountNo, symbol, side, quantity, activePrice, trailingAmount, fromDate, toDate, code?)Place a Trailing Stop Market order
placeFcoTrailingStopLimit(accountNo, symbol, side, quantity, activePrice, trailingAmount, priceSlip, fromDate, toDate, code?)Place a Trailing Stop Limit order
placeFcoOco(accountNo, symbol, side, quantity, tpActivePrice, slActivePrice, tpPrice, slPrice, tpSlip, slSlip, fromDate, toDate, code?)Place an OCO (One Cancels the Other) order
placeFcoBullBear(accountNo, symbol, side, quantity, price, priceSlip, tpActivePrice, slActivePrice, tpPrice, slPrice, tpSlip, slSlip, fromDate, toDate, code?)Place a Bull Bear order
cancelFco(fcoId, code?)Cancel an FCO by fcoId
getFcoByAccountNo(accountNo, pageIndex?, pageSize?)List FCOs by account
getFcoBySymbol(accountNo, symbol, pageIndex?, pageSize?)List FCOs filtered by symbol
getFcoByStatus(accountNo, processStatus, pageIndex?, pageSize?)List FCOs filtered by status. processStatus takes a single value or an array
getFcoByDate(accountNo, fromDate, toDate, pageIndex?, pageSize?)List FCOs filtered by date range
getFcoById(accountNo, fcoId)Get one FCO by ID (undefined if not found)
getFcoOrderBook(fcoId, pageIndex?, pageSize?)Get the execution history (order book) of an FCO. Defaults: pageIndex 1, pageSize 10
getFcoStatusHistory(fcoId)Get the status history of an FCO (FCOStatusHistoryItem[], empty if none yet)

5.6. Batch Orders

Place or cancel several orders in one signed REST request instead of calling placeOrder per order (for example when rebalancing a portfolio). This is the one place with the full batch description. For the data types see Models, for the order-count limit see Configuration, and for errors see Error Handling. Batches over WebSocket are in section 7.

Prerequisites

  • Use the Trading client. The methods live on trading.trading (TradingService).
  • You need a token with trading access, which means authenticating with an OTP (auth.authenticate(otp) or auth.ensureAuthenticated(otp)).
  • Config.privateKey is required: the SDK signs the whole body with RSA and sends the signature in the X-Signature header.
  • The per-batch order limit is Config.maxBatchOrders (default 20).
MethodParametersReturnsDescription
placeBatchOrders(orders)orders: readonly BatchPlaceOrderItem[]Promise<BatchOrderResponse>Place a batch of orders (POST /api/v3/trading/order/batch)
cancelBatchOrders(orders)orders: readonly BatchCancelOrderItem[]Promise<BatchOrderResponse>Cancel a batch of orders (DELETE /api/v3/trading/order/batch)

Both methods:

  • validate the whole batch on the client first. A single bad order throws ValidationError and no request is sent;
  • generate batchRequestId (random) and batchRequestTime (current time, epoch ms) for you. You do not pass them;
  • serialize and sign the body once, over exactly the text that is sent;
  • are never retried (see "Idempotency and retries" below).

An empty batch, or one larger than maxBatchOrders, is rejected locally with ValidationError. The SDK only enforces the limit on the client side. The SDK default is 20, while the API reference states a maximum of 50 orders per batch: see the note in Configuration.

Example 1: place a batch and read each result

import {
  Auth, Config, Trading, OrderSide, OrderType,
  DuplicateRequestError, ValidationError,
} from '@ssi.developer/ssi-sdk';

const config = new Config({
  clientId: 'YOUR_CLIENT_ID',
  apiKey: 'YOUR_API_KEY',
  apiSecret: 'YOUR_API_SECRET',
  privateKey: 'YOUR_PRIVATE_KEY',
});

const auth = new Auth(config);
await auth.authenticate('YOUR_OTP'); // your OTP
const trading = new Trading(auth);

const accountNo = 'YOUR_ACCOUNT_NO';
const tag = Date.now().toString(36); // clientRequestId is at most 20 characters

const items = [
  { accountNo, symbol: 'SSI', side: OrderSide.BUY, quantity: 100, price: '25.5', orderType: OrderType.LO, clientRequestId: `b${tag}-1` },
  { accountNo, symbol: 'VNM', side: OrderSide.BUY, quantity: 100, price: '60', orderType: OrderType.LO, clientRequestId: `b${tag}-2` },
];

try {
  const response = await trading.trading.placeBatchOrders(items);
  console.log(`Batch: ${response.batchRequestId}`);

  for (const r of response.results) {
    if (r.success) {
      console.log(`OK   ${r.clientRequestId} -> orderId ${r.orderId}, status ${r.status}`);
    } else {
      console.log(`FAIL ${r.clientRequestId}: ${r.errorCode} ${r.errorMessage}`);
    }
  }
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(`Invalid batch, nothing was sent: ${error.message}`);
  } else if (error instanceof DuplicateRequestError) {
    // The id was already used today: the original order may still be live. Check the order book, do not resend under another id.
    console.error(`Duplicate id (HTTP 409): ${error.message}`);
  } else {
    throw error;
  }
}

Example 2: cancel a batch by orderId

Take the orderId of the orders accepted in the previous example and cancel them in one batch.

const accepted = response.results.filter((r) => r.success && r.orderId);

if (accepted.length > 0) {
  const cancelled = await trading.trading.cancelBatchOrders(
    accepted.map((r) => ({ accountNo, orderId: r.orderId })),
  );

  for (const r of cancelled.results) {
    console.log(`${r.success ? 'OK  ' : 'FAIL'} orderId ${r.orderId} | ${r.status} ${r.errorCode} ${r.errorMessage}`);
  }
}

You can cancel by clientRequestId instead of orderId: { accountNo, clientRequestId: 'YOUR_CLIENT_REQUEST_ID' }. Each cancel needs accountNo and exactly one of the two ids.

Signing

The SDK serializes the body to a JSON string exactly once, signs that string with RSA and sends that same string. The signature covers the whole body (batchRequestId, batchRequestTime and the orders array) and goes in the X-Signature header. So:

  • Do not modify the body after signing (relevant only if you use the low-level REST client).
  • Without Config.privateKey the request cannot be signed.
  • Never log the signature, the private key or tokens.

Idempotency and retries

  • Each order has a clientRequestId (at most 20 characters) that works as its idempotency key. Set your own so you can match it with the order book and order events.
  • Each batch has a random batchRequestId. An id already used today gets HTTP 409 from the server and the SDK throws DuplicateRequestError.
  • A batch is never retried automatically, not even on a timeout or network error. The server may already have accepted it.
  • After a DuplicateRequestError or a lost response, check the order state first (for example trading.portfolio.getTodayOrders(accountNo), or order events on the Stream). Do not resend the same orders under a new id.
  • If batchRequestTime falls outside the time window the server accepts (the replay window), the server rejects it with code 400111. Check your machine clock if you see this repeatedly. The API reference states this window as 30 seconds.

Handling per-order results

  • According to the SDK documentation, the server accepts or rejects the whole batch, and the SDK also validates every order before sending. How the response really behaves when only some orders fail is not confirmed by the source.
  • Even so, the response always carries one BatchOrderResult per order. Iterate results and check success, errorCode and errorMessage for each order instead of treating the whole batch as a success.
  • results follows the order of the array you sent. You can also match by clientRequestId.
  • To follow status and fills afterwards, use order events on the Stream (subscribeOrderStatus) or the order book.

See also Tutorial 14: Batch Orders.

Trading Method Summary

GroupMethodReturns
Place ordersplaceOrder(accountNo, symbol, side, qty, price, orderType, clientRequestId?)PlaceOrderResponse
placeLimitOrder(accountNo, symbol, side, qty, price, clientRequestId?)PlaceOrderResponse
placeMarketOrder(accountNo, symbol, side, qty, clientRequestId?)PlaceOrderResponse
placeAtoOrder(accountNo, symbol, side, qty, clientRequestId?)PlaceOrderResponse
placeAtcOrder(accountNo, symbol, side, qty, clientRequestId?)PlaceOrderResponse
Modify ordersmodifyOrderPrice(accountNo, clientRequestId, price)ModifyOrderResponse
modifyOrderPriceByOrderId(accountNo, orderId, price)ModifyOrderResponse
modifyOrderQuantity(accountNo, clientRequestId, qty)ModifyOrderResponse
modifyOrderQuantityByOrderId(accountNo, orderId, qty)ModifyOrderResponse
Cancel orderscancelOrder(accountNo, clientRequestId)CancelOrderResponse
cancelOrderById(accountNo, orderId)CancelOrderResponse
Batch (see 5.6)placeBatchOrders(orders)BatchOrderResponse
cancelBatchOrders(orders)BatchOrderResponse
Max buy/sellgetMaxBuySell(accountNo, symbol, price)MaxBuySellResponse
getMaxBuySellAtMarketPrice(accountNo, symbol)MaxBuySellResponse
Conditional ordersplaceFcoGtd(...)FCOPlaceResponse
placeFcoStop(...) / placeFcoStopLimit(...)FCOPlaceResponse
placeFcoTrailingStop(...) / placeFcoTrailingStopLimit(...)FCOPlaceResponse
placeFcoOco(...) / placeFcoBullBear(...)FCOPlaceResponse
cancelFco(fcoId, code?)FCOCancelResponse
getFcoByAccountNo(...) / getFcoBySymbol(...) / getFcoByStatus(...) / getFcoByDate(...)FCOListResponse
getFcoById(accountNo, fcoId)FCOInfo | undefined
getFcoOrderBook(fcoId, pageIndex?, pageSize?)FCOOrderBookResponse
getFcoStatusHistory(fcoId)FCOStatusHistoryItem[]

Placing orders over WebSocket: see section 7.


6. Realtime Streaming (StreamingService)

Access via stream.streaming. Call await stream.streaming.connect() first. Every subscribe/unsubscribe method returns Promise<void>: always use await.

6.1. Connection

const stream = new Stream(auth);

await stream.streaming.connect();

// Wait indefinitely
await stream.streaming.wait();

// Or wait at most 60 seconds
await stream.streaming.wait(60_000);

wait(timeoutMs?) waits until the connection ends for good or the time runs out. Related properties: isConnected, lastError, reconnectCount (see 6.8).

6.2. Setup Callbacks

stream.streaming.onData = (msg) => {
  console.log('[DATA]', msg);
};

stream.streaming.onTrading = (msg) => {
  console.log('[TRADING]', msg);
};

stream.streaming.onHeartbeat = (msg) => {
  console.log('[HEARTBEAT]', msg);
};

Besides these three there are typed callbacks: onMarketFlag (needs subscribeMarketFlag), onIndexSummary, onOrder, onOrderMatch, onPortfolio and onConnection (receives a ConnectionEvent). Messages still reach onData/onTrading if you set both. Setting a callback subscribes to nothing, it only chooses where messages are delivered. An onResponse callback passed to a subscribe method receives only the server's acknowledgement frame. If a callback throws, the SDK logs it and still calls the remaining callbacks.

6.3. Subscribe Market Data

import { Board, Timeframe } from '@ssi.developer/ssi-sdk';

// All channels (trade + quote + room) for symbols
await stream.streaming.subscribeSymbol(['SSI', 'HPG', 'VIC']);

// Individual channels
await stream.streaming.subscribeSymbolTrade(['SSI', 'HPG']);
await stream.streaming.subscribeSymbolQuote(['SSI', 'HPG']);
await stream.streaming.subscribeSymbolRoom(['SSI', 'HPG']);
await stream.streaming.subscribeSymbolPutThrough(['SSI']);
await stream.streaming.subscribeSymbolOddLot(['SSI']);

// OHLCV by timeframe (only tick, 1m, 5m)
await stream.streaming.subscribeSymbolOhlcv(['SSI'], Timeframe.MINUTE_1);

// By board
await stream.streaming.subscribeBoard([Board.HOSE, Board.HNX]);

// By index (constituent stocks)
await stream.streaming.subscribeIndex(['VNINDEX', 'VN30']);

// Market session flag and master data
await stream.streaming.subscribeMarketFlag();
await stream.streaming.subscribeMarket(['SSI']);

subscribeIndex and subscribeBoard are sent with an @* suffix so the server fans out by symbol (for example trade.VN30@*). This @* behaviour comes from reading the server code and is not confirmed on a live server. Both methods generate heavy traffic. Each element of the list is one code: a comma-separated string is rejected with ValidationError. For the data of the index itself (ticks, candles, summaries) see 6.7.

There are also equivalent shorter names (aliases) with matching unsubscribe pairs: subscribeTrades, subscribeQuotes, subscribeForeignRoom, subscribePutThrough, subscribeOddLot, subscribeCandles, subscribeExchangeTrades, subscribeIndexConstituents, subscribeSessionFlag, subscribeMasterData.

6.4. Unsubscribe

await stream.streaming.unsubscribeSymbol(['SSI', 'HPG']);
await stream.streaming.unsubscribeSymbolTrade(['SSI']);
await stream.streaming.unsubscribeSymbolQuote(['SSI']);
await stream.streaming.unsubscribeSymbolRoom(['SSI']);
await stream.streaming.unsubscribeSymbolPutThrough(['SSI']);
await stream.streaming.unsubscribeSymbolOddLot(['SSI']);
await stream.streaming.unsubscribeSymbolOhlcv(['SSI'], Timeframe.MINUTE_1);
await stream.streaming.unsubscribeBoard([Board.HOSE]);
await stream.streaming.unsubscribeIndex(['VNINDEX']);

6.5. Subscribe Trading Events

// Order status (regular + FCO) — all accounts
await stream.streaming.subscribeOrderStatus();

// For a specific account
await stream.streaming.subscribeOrderStatus('YOUR_ACCOUNT_NO');

// Portfolio — all accounts
await stream.streaming.subscribePortfolio();

// For a specific account
await stream.streaming.subscribePortfolio('YOUR_ACCOUNT_NO');

// Unsubscribe
await stream.streaming.unsubscribeOrderStatus('YOUR_ACCOUNT_NO');
await stream.streaming.unsubscribePortfolio('YOUR_ACCOUNT_NO');

The order.<account> channel returns both OrderStatusMessage (regular orders) and FCOOrderUpdateMessage (FCO conditional orders). In onTrading the SDK classifies by data.eventType: 'fcoEvent' is an FCO order, 'orderEvent' a regular order, 'orderMatchEvent' a fill and 'clientPortfolioEvent' a portfolio update. For frames without eventType, the topic prefix order. or portfolio. decides. If you use the typed callbacks onOrder, onOrderMatch and onPortfolio you do not have to classify anything yourself. subscribeFcoOrderStatus is deprecated: FCO updates arrive on the same order.<account> channel, so subscribeOrderStatus is enough. See Models for the messages.

6.6. Heartbeat

await stream.streaming.ping();

// Or start a background ping loop every 30 seconds
await stream.streaming.ping(undefined, 30_000);

The SDK does not ping by itself. The background ping loop stops when you call disconnect().

6.7. Real-time Index Data

Receive data of the index itself (VNINDEX, VN30, HNXINDEX...) in real time: ticks, 1-minute / 5-minute candles and the index summary. Use it to chart an index, show market breadth or follow an index without aggregating per-symbol prices yourself. If you need the constituent stocks of an index, use subscribeIndex (see the comparison table below). The field tables of IndexTickMessage, IntervalMessage and IndexSummaryMessage are in Models.

Prerequisites

  • Use the Stream client with an authenticated Auth (as in 6.1). The methods live on stream.streaming (StreamingService).
  • Call await stream.streaming.connect() before subscribing.
MethodParametersReturnsDescription
subscribeIndexTrade(indices, interval?, onResponse?)indices: string[], interval?: Timeframe | StreamInterval | null, onResponse?Promise<void>Ticks of the index (trade.index.<code>). With a 1m/5m interval you get candles
unsubscribeIndexTrade(indices, interval?)indices: string[], interval?Promise<void>Unsubscribe. Pass the same interval you subscribed with
subscribeIndexSummary(indices, onResponse?)indices: string[], onResponse?Promise<void>Index summary (indexsummary.<code>)
unsubscribeIndexSummary(indices)indices: string[]Promise<void>Unsubscribe
listSubscription(timeoutMs = 5000)timeoutMs?: numberPromise<{ trading: string[]; data: string[] }>Ask the server which topics this connection is subscribed to
unsubscribeAll()nonePromise<{ trading: string[]; data: string[] }>Unsubscribe everything the server reports for this connection and return the removed topics

Shorter equivalent names: subscribeIndexTicks = subscribeIndexTrade, unsubscribeIndexTicks = unsubscribeIndexTrade.

Index code and interval rules

  • Each element of indices is one code (VNINDEX, VN30...). Codes are upper-cased automatically.
  • Pass an array such as ['VNINDEX', 'VN30'], not a comma-separated string ('VNINDEX,VN30' is rejected).
  • Write codes as one word, for example HNXINDEX (not HNX-INDEX). UPCOMINDEX is sent as HNXUPCOMINDEX by the SDK.
  • interval accepts only tick, 1m or 5m (STREAM_INTERVALS). Omit interval or use tick to get ticks. Any other value throws ValidationError locally.
  • Timeframe.MINUTE_1 ('1m') and Timeframe.MINUTE_5 ('5m') work. Other Timeframe values are rejected.
  • For trade.index, an interval goes with one concrete code only, not * and not an A-B range.
  • Each code and interval combination is a separate topic (trade.index.VN30 and trade.index.VN30@1m are independent).

subscribeIndex vs subscribeIndexTrade

MethodWhat you get
subscribeIndexTrade(['VN30'])Ticks of the index itself, VN30 (IndexTickMessage)
subscribeIndex(['VN30'])Trade, quote and room of the constituent stocks of VN30 (sent as trade.VN30@* and so on)

Handlers

Index ticks, candles and index summaries all pass through onData (DataMessage). Index summaries can also be received with onIndexSummary (IndexSummaryMessage). There is no dedicated handler for index ticks: use onData and identify the type (see the example).

Example 1: index ticks and summary with connection events

import { Auth, Config, Stream } from '@ssi.developer/ssi-sdk';
import type { DataMessage, IndexTickMessage } from '@ssi.developer/ssi-sdk';

const config = new Config({
  clientId: 'YOUR_CLIENT_ID',
  apiKey: 'YOUR_API_KEY',
  apiSecret: 'YOUR_API_SECRET',
});

const auth = new Auth(config);
await auth.authenticate('YOUR_OTP'); // your OTP

const stream = new Stream(auth);
const s = stream.streaming;

// Only IndexTickMessage has a `raw` field
const isIndexTick = (msg: DataMessage): msg is IndexTickMessage => 'raw' in msg;

s.onConnection = (event) => {
  console.log(`[CONNECTION] ${event.state} | reconnectCount: ${event.reconnectCount}` +
    (event.error ? ` | error: ${event.error.message}` : ''));
};

s.onData = (msg) => {
  if (isIndexTick(msg)) {
    console.log(`[INDEX] ${msg.symbol} ${msg.value} (${msg.change}, ${msg.changePercent}%)`);
  }
};

s.onIndexSummary = (msg) => {
  console.log(`[SUMMARY] ${msg.index} | ${Object.keys(msg.data).length} fields`);
};

await s.connect();
await s.subscribeIndexTrade(['VNINDEX', 'VN30']);
await s.subscribeIndexSummary(['VNINDEX']);
await s.ping(undefined, 30_000); // the SDK does not ping by itself: start the background ping loop

await s.wait(60_000); // receive data for 60 seconds

await s.unsubscribeIndexTrade(['VNINDEX', 'VN30']);
await s.unsubscribeIndexSummary(['VNINDEX']);
await stream.disconnect();

Example 2: 1-minute index candles and checking subscriptions

import { Timeframe } from '@ssi.developer/ssi-sdk';
import type { IntervalMessage } from '@ssi.developer/ssi-sdk';

const INDICES = ['VN30'];

s.onData = (msg) => {
  // Candles have an intervalTime field. Filter by symbol to keep index candles only
  if ('intervalTime' in msg && INDICES.includes((msg as IntervalMessage).symbol)) {
    const c = msg as IntervalMessage;
    console.log(`[CANDLE] ${c.symbol} ${c.intervalTime} O${c.open} H${c.high} L${c.low} C${c.close} V${c.volume}`);
  }
};

await s.subscribeIndexTrade(INDICES, Timeframe.MINUTE_1);

const subs = await s.listSubscription();
console.log(`data topics: ${subs.data.join(', ')}`);

// ... when you are done ...
await s.unsubscribeIndexTrade(INDICES, Timeframe.MINUTE_1); // same interval you subscribed with

Errors and notes

SituationResult
interval is not tick/1m/5m, a badly formatted code (comma, odd characters), or an empty indicesValidationError locally, nothing is sent
Subscribing before connect()WebSocketError (Not connected. Call connect() first.). Call connect() first
listSubscription gets no reply within timeoutMs, or you are not connectedWebSocketError
The connection cannot be recoveredonConnection receives failed and lastError is set. A failed first connect() throws WebSocketError or AuthenticationError
A callback throwsThe SDK logs it and still calls the remaining callbacks
  • The @* behaviour for boards/indices and the indexsummary data depend on the server and are not confirmed on a live server. If no data arrives, use listSubscription() to check that the topic was registered.
  • The tick field names follow the abbreviations the server sends, and their meaning was inferred from the abbreviation and not confirmed on a live frame (see Models).
  • For constituent stocks and stock candles, use the symbol/board methods in 6.3.

See also Tutorial 16: Advanced WebSocket Streaming and Tutorial 10: WebSocket Market Data.

6.8. Connection Events and Reconnect

onConnection receives a ConnectionEvent:

FieldTypeDescription
state'connected' | 'reconnected' | 'disconnected' | 'failed'State
reconnectCountnumberHow many times the socket was re-opened after a drop
errorError?Accompanying error, if any

Related properties: isConnected, reconnectCount, lastError (the error that ended the connection for good, null while healthy) and wait(timeoutMs?).

When Config.autoReconnect is on (default true), the SDK keeps the connection alive: it re-opens the socket after a drop with exponential backoff and jitter (capped at 60000 ms), always with a fresh token, re-opens it ahead of token expiry, and re-sends every subscription. You do not need to re-subscribe by hand after reconnected. If the server refuses the connection with 401/403, the SDK refreshes the token once. The server allows at most 10 sockets per client, and the SDK always closes the old socket before opening a new one. If the server answers 429 on connect, the SDK waits at least 30000 ms. Large subscribe requests are split into frames under 4096 bytes.

Streaming Method Summary

GroupMethodDescription
Connectionconnect()Connect WebSocket
disconnect()Disconnect (Promise<void>)
wait(timeoutMs?)Wait until the connection ends for good or the time runs out
Heartbeatping(onResponse?, intervalMs?)Ping the server (optionally repeating)
SymbolsubscribeSymbol(symbols, onResponse?)Trade + Quote + Room
subscribeSymbolTrade(symbols, onResponse?)Trade only
subscribeSymbolQuote(symbols, onResponse?)Quote only
subscribeSymbolRoom(symbols, onResponse?)Foreign room only
subscribeSymbolPutThrough(symbols, onResponse?)Put-through only
subscribeSymbolOddLot(symbols, onResponse?)Odd-lot only
subscribeSymbolOhlcv(symbols, interval, onResponse?)OHLCV by timeframe (tick, 1m, 5m)
Board/IndexsubscribeBoard(boards, onResponse?)By board
subscribeIndex(indices, onResponse?)Constituent stocks of an index
subscribeIndexTrade(indices, interval?, onResponse?)Ticks/candles of the index itself (see 6.7)
subscribeIndexSummary(indices, onResponse?)Index summary
MarketsubscribeMarketFlag()Market session flag (needed for onMarketFlag)
subscribeMarket(symbols)Master data
TradingsubscribeOrderStatus(accountNo = '*', onResponse?)Order status (regular + FCO)
subscribePortfolio(accountNo = '*', onResponse?)Portfolio changes
subscribeFcoOrderStatus(accountNo?, onResponse?)Deprecated, use subscribeOrderStatus
UnsubscribeunsubscribeSymbol(symbols)Unsubscribe all channels
unsubscribeSymbolTrade(symbols)Unsubscribe trade
unsubscribeSymbolQuote(symbols)Unsubscribe quote
unsubscribeSymbolRoom(symbols)Unsubscribe room
unsubscribeSymbolPutThrough(symbols)Unsubscribe put-through
unsubscribeSymbolOddLot(symbols)Unsubscribe odd-lot
unsubscribeSymbolOhlcv(symbols, interval)Unsubscribe OHLCV
unsubscribeBoard(boards)Unsubscribe board
unsubscribeIndex(indices)Unsubscribe index
unsubscribeIndexTrade(indices, interval?) / unsubscribeIndexSummary(indices)Unsubscribe ticks/candles, unsubscribe index summary
unsubscribeOrderStatus(accountNo?) / unsubscribePortfolio(accountNo?)Unsubscribe trading events
QuerylistSubscription(timeoutMs = 5000)Active topics as { trading, data }
unsubscribeAll()Unsubscribe every topic the server reports for this connection

7. Trading WebSocket

The Trading WebSocket lets you place, amend and cancel orders, work with conditional orders (FCO) and query the account over one socket wss://.../ws/v3/trading, instead of one REST request per call. Use it when you want order commands, order events and queries on a single connection. For the option table see Configuration, for TradingWSResponse see Models, and for errors see Error Handling.

7.1. Prerequisites and connection

  • Authenticated with an OTP: await auth.ensureAuthenticated(otp). The connect function takes the token from Auth.
  • Config.privateKey is required for state-changing commands (place, amend, cancel, batch, FCO). Without it the SDK throws WebSocketError. Queries and subscriptions are not signed.
  • Only wss:// is accepted.
  • It is separate from Trading and Stream: those clients never connect to this socket.

Everything is exported from the main package @ssi.developer/ssi-sdk. tradingWs is an alias of asyncTradingWs.

Breaking change in 3.3.2

The Trading WebSocket is no longer experimental. The @ssi.developer/ssi-sdk/experimental entry point was removed with no alias or deprecated re-export, so imports from it fail until you change them. Names, signatures and defaults are unchanged.

Before: import { asyncTradingWs } from '@ssi.developer/ssi-sdk/experimental';

After: import { Auth, asyncTradingWs } from '@ssi.developer/ssi-sdk';

import { Auth, Config, asyncTradingWs, TradingWSError } from '@ssi.developer/ssi-sdk';

const config = new Config({
  clientId: 'YOUR_CLIENT_ID',
  apiKey: 'YOUR_API_KEY',
  apiSecret: 'YOUR_API_SECRET',
  privateKey: 'YOUR_PRIVATE_KEY',
});

const auth = new Auth(config);
await auth.ensureAuthenticated('YOUR_OTP'); // your OTP

const ws = await asyncTradingWs(auth); // connects to Config.tradingWsUrl
// ... use ws ...
await ws.close();

The URL comes from Config.tradingWsDomain, which defaults to wss://api.ssi.com.vn/ws/v3/trading. A bare host (host[:port]) gets wss:// and /ws/v3/trading added. A URL without a path gets the /ws/v3/trading path. Read config.tradingWsUrl to see the final URL.

FunctionParametersReturnsDescription
asyncTradingWs(auth, options?)auth: Auth, options: TradingWSClientOptionsPromise<TradingWSService>Connects and returns the service. requestTimeoutMs and userAgent default to the Config values

7.2. TradingWSService

Methods of TradingWSService (all return a Promise). The last column gives the command name on the wire.

Orders

MethodParametersReturnsCommand
placeOrderaccountNo, symbol, side, quantity, price, orderType, clientRequestId?PlaceOrderResponseorder.place
amendOrderaccountNo, price?, quantity?, orderId?, clientRequestId?ModifyOrderResponseorder.amend
cancelOrderaccountNo, orderId?, clientRequestId?CancelOrderResponseorder.cancel
batchNewOrdersorders: readonly BatchPlaceOrderItem[]TradingWSResponseorder.batchNew
batchCancelOrdersorders: readonly BatchCancelOrderItem[]TradingWSResponseorder.batchCancel

Place, amend and cancel return the immediate ack, with orderStatus PD. The real outcome of the order arrives through order events (subscribeOrderEvents). Commands are never retried. Batches over WebSocket use the same item types and the same Config.maxBatchOrders limit as REST, see 5.6.

Conditional orders (FCO)

MethodParametersReturnsCommand
placeFcoorder: FcoParamsKind ({ kind: 'gtd' | 'stop' | 'trailingStop' | 'oco' | 'bullBear', params })FCOPlaceResponsefco.place
cancelFcofcoId, code?FCOCancelResponsefco.cancel
listFcoaccountNo, options?: FcoListOptionsTradingWSResponsefco.list
fcoOrderBookfcoIdTradingWSResponsefco.orderbook
fcoStatusHistoryfcoIdTradingWSResponsefco.statusHistory

listFco takes a single processStatus, not an array. fcoOrderBook sends the defaults pageIndex=1, pageSize=10.

Queries

MethodParametersReturnsCommand
queryPositionaccountNo, querySummary = trueTradingWSResponsequery.position
queryMaxBuySellaccountNo, symbol, price?TradingWSResponsequery.maxBuySell
queryAccountBalanceaccountNoTradingWSResponsequery.accountBalance
queryPpmmrAccountaccountNoTradingWSResponsequery.ppmmrAccount
queryOrderBookaccountNo, query?: OrderBookQueryTradingWSResponsequery.orderBook
  • queryMaxBuySell: omit price and the server uses its reference price.
  • OrderBookQuery has fromDate, toDate (YYYY/MM/DD), symbol, orderStatus, pageIndex and pageSize.

Events and utilities

MethodParametersDescription
subscribeOrderEvents(accountNo)stringOrder events for one account (order.<account>)
unsubscribeOrderEvents(accountNo)stringUnsubscribe
subscribePortfolioEvents(accountNo)stringPortfolio events for one account (portfolio.<account>)
unsubscribePortfolioEvents(accountNo)stringUnsubscribe
unsubscribeAllEvents()noneDrop every trading subscription of this socket
command(method, params)string, Record<string, unknown>Send any command and return its first response frame (no retry)
close()noneClose the socket
client (getter)The underlying TradingWSClient: onEvent, rateLimit, isConnected, rejection

Event subscriptions take one account at a time, with no * wildcard and no list. Passing * or a comma throws ValidationError.

7.3. Examples

Example 1: connect, receive events, query

import { Auth, Config, asyncTradingWs, TradingWSError } from '@ssi.developer/ssi-sdk';

const config = new Config({
  clientId: 'YOUR_CLIENT_ID',
  apiKey: 'YOUR_API_KEY',
  apiSecret: 'YOUR_API_SECRET',
  privateKey: 'YOUR_PRIVATE_KEY',
});
const auth = new Auth(config);
await auth.ensureAuthenticated('YOUR_OTP'); // your OTP

const accountNo = 'YOUR_ACCOUNT_NO';

let ws;
try {
  ws = await asyncTradingWs(auth);
} catch (error) {
  // The environment may not support the trading WebSocket yet: use REST instead
  console.error(`Could not connect: ${error.message}`);
  process.exit(1);
}

// Frames that belong to no command (order events, portfolio events, pong...) go to onEvent.
// Frames are raw objects, the SDK does not parse them into models.
ws.client.onEvent = (frame) => {
  console.log('[EVENT]', frame);
};

try {
  await ws.subscribeOrderEvents(accountNo);
  await ws.subscribePortfolioEvents(accountNo);

  const position = await ws.queryPosition(accountNo, true);
  console.log(`position: status ${position.status}`, position.result);

  const max = await ws.queryMaxBuySell(accountNo, 'SSI'); // no price: the server uses its reference price
  console.log(`maxBuySell: status ${max.status}`, max.result);

  const book = await ws.queryOrderBook(accountNo, { pageIndex: 1, pageSize: 10 });
  console.log(`orderBook: status ${book.status}`, book.result);
} catch (error) {
  if (error instanceof TradingWSError) {
    console.error(`Server refused: status=${error.status} code=${error.errorCode} msg=${error.serverMessage}`);
  } else {
    console.error(`Error: ${error.message}`);
  }
} finally {
  await ws.unsubscribeAllEvents().catch(() => undefined);
  await ws.close();
}

Example 2: place an order, read the ack, watch the rate limit

import { OrderSide, OrderType } from '@ssi.developer/ssi-sdk';

const clientRequestId = `w${Date.now().toString(36)}`; // at most 20 characters

const ack = await ws.placeOrder(
  accountNo, 'SSI', OrderSide.BUY, 100, '25.5', OrderType.LO, clientRequestId,
);
console.log(`Ack: orderId ${ack.orderId || '-'} | status ${ack.status}`); // an ack, status PD
console.log('rateLimit:', ws.client.rateLimit);

// The real outcome arrives on ws.client.onEvent (after subscribeOrderEvents)
if (ack.orderId) {
  const cancelled = await ws.cancelOrder(accountNo, ack.orderId);
  console.log(`Cancel: status ${cancelled.status}`);
}

Pass the price as a plain decimal string (PriceLike), with no exponent and no thousands separator. The order above is a real order, so run it on a test account only.

7.4. Errors and notes

ErrorWhen
TradingWSErrorThe server returned an error frame, or refused the connection after accepting the socket. Has status, errorCode, serverMessage. Extends WebSocketError
WebSocketErrorNot connected (not connected), send failed, no response in time (not retried), socket closed before a response, or no private key for a state-changing command
ValidationErrorBad input caught locally (for example subscribing to several accounts, or an empty or oversized batch). Because the methods are async, this rejects the Promise
  • Refusal after the upgrade: the server accepts the socket (HTTP 101) and only then sends a {code, msg} frame to refuse it. So connect waits handshakeGraceMs (500 ms) and throws TradingWSError if that frame shows up. You can also read it through ws.client.rejection.
  • Signature refused: for error codes 401006, 401008 and 401013 in signatureEncoding: 'auto' mode, the SDK retries exactly once with the other signature form (the server refused the signature, so the command was not executed). If both are refused, the error adds a hint to check that Config.privateKey matches the API key. If every signed command fails, try setting signatureEncoding explicitly.
  • Lost ack: on a timeout the command may already have been accepted by the server. The SDK does not retry. Reconcile through order events (order.<account>) or queryOrderBook before resending.
  • Security: the SDK never logs params, signatures, tokens or account numbers. When you log frames or results yourself, mask account numbers first. Never put the private key, API secret or an OTP in source code or logs: use environment variables or a secret manager.

7.5. Reconnect and rate limit

Reconnect. The Trading WebSocket has no automatic reconnect and does not re-subscribe. If the socket closes, ws.client.isConnected becomes false and pending commands fail with WebSocketError. In that case call ws.close(), open a new connection with asyncTradingWs(auth) and subscribe again. By default the SDK sends a ping (PING_PONG) every 30000 ms to keep the connection alive.

Rate limit. After each response the SDK stores the rateLimit the server sent in ws.client.rateLimit. When remaining is 0 and respectRateLimit is on, the next command waits up to maxRateLimitWaitMs (default 2000 ms, or resetMs if smaller) before it is sent. If the server still answers 429, you get a TradingWSError with status 429: wait, then retry in a controlled way. Other rateLimit fields besides remaining and resetMs are decided by the server and are not guaranteed by the SDK.

7.6. Relation to the Stream client

Stream client (Stream)Trading WebSocket (asyncTradingWs)
EndpointConfig.streamingUrl (default wss://stream.ssi.com.vn/ws/v3)Config.tradingWsUrl (default wss://api.ssi.com.vn/ws/v3/trading)
PurposeReal-time market data and order/portfolio eventsPlace/amend/cancel orders, FCO, account queries, order/portfolio events
ReconnectYes: reopens, refreshes the token, re-subscribesNo
Account subscription* allowedOne account at a time
Event dataTyped models (onOrder, onOrderMatch, onPortfolio)Raw frames via client.onEvent

The two clients use separate sockets and are independent. For market data keep using Stream (see section 6).

See also Tutorial 17: Trading WebSocket.

On this page