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:
| Method | Timeframe |
|---|---|
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):
| Method | Timeframe |
|---|---|
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:
| Parameter | Type | Default | Description |
|---|---|---|---|
symbol | string | (required) | Security symbol |
from | string | (required) | Start date (yyyy/MM/dd) |
to | string | (required) | End date (yyyy/MM/dd) |
page | number | 1 | Page number. Less than 1 is refused locally |
size | number | 1000 | Records 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:
| Method | Description |
|---|---|
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):
| Method | Parameters | Returns | Description |
|---|---|---|---|
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:
fromandtouse theYYYY/MM/DDformat,fromis not afterto, 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.languageis'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
| Group | Method | Returns |
|---|---|---|
| Balance | getEquityBalance(accountNo) | EquityAccountBalance | undefined |
getDerivativeBalance(accountNo) | DerivativeAccountBalance | undefined | |
| Positions | getEquityPositions(accountNo) | EquityPosition[] | undefined |
getDerivativePositions(accountNo) | AllDerivativePosition | undefined | |
getOpenDerivativePositions(accountNo) | DerivativePosition[] ([] if none) | |
getClosedDerivativePositions(accountNo) | DerivativePosition[] ([] if none) | |
| Order book | getTodayOrders(accountNo) | Order[] |
getHistoricalOrders(accountNo, from, to) | Order[] | |
| PPMMR | getEquityPpmmr(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);| Method | Description |
|---|---|
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
Tradingclient. The methods live ontrading.trading(TradingService). - You need a token with trading access, which means authenticating with an OTP (
auth.authenticate(otp)orauth.ensureAuthenticated(otp)). Config.privateKeyis required: the SDK signs the whole body with RSA and sends the signature in theX-Signatureheader.- The per-batch order limit is
Config.maxBatchOrders(default 20).
| Method | Parameters | Returns | Description |
|---|---|---|---|
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
ValidationErrorand no request is sent; - generate
batchRequestId(random) andbatchRequestTime(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.privateKeythe 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 throwsDuplicateRequestError. - A batch is never retried automatically, not even on a timeout or network error. The server may already have accepted it.
- After a
DuplicateRequestErroror a lost response, check the order state first (for exampletrading.portfolio.getTodayOrders(accountNo), or order events on the Stream). Do not resend the same orders under a new id. - If
batchRequestTimefalls outside the time window the server accepts (the replay window), the server rejects it with code400111. 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
BatchOrderResultper order. Iterateresultsand checksuccess,errorCodeanderrorMessagefor each order instead of treating the whole batch as a success. resultsfollows the order of the array you sent. You can also match byclientRequestId.- 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
| Group | Method | Returns |
|---|---|---|
| Place orders | placeOrder(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 orders | modifyOrderPrice(accountNo, clientRequestId, price) | ModifyOrderResponse |
modifyOrderPriceByOrderId(accountNo, orderId, price) | ModifyOrderResponse | |
modifyOrderQuantity(accountNo, clientRequestId, qty) | ModifyOrderResponse | |
modifyOrderQuantityByOrderId(accountNo, orderId, qty) | ModifyOrderResponse | |
| Cancel orders | cancelOrder(accountNo, clientRequestId) | CancelOrderResponse |
cancelOrderById(accountNo, orderId) | CancelOrderResponse | |
| Batch (see 5.6) | placeBatchOrders(orders) | BatchOrderResponse |
cancelBatchOrders(orders) | BatchOrderResponse | |
| Max buy/sell | getMaxBuySell(accountNo, symbol, price) | MaxBuySellResponse |
getMaxBuySellAtMarketPrice(accountNo, symbol) | MaxBuySellResponse | |
| Conditional orders | placeFcoGtd(...) | 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
Streamclient with an authenticatedAuth(as in 6.1). The methods live onstream.streaming(StreamingService). - Call
await stream.streaming.connect()before subscribing.
| Method | Parameters | Returns | Description |
|---|---|---|---|
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?: number | Promise<{ trading: string[]; data: string[] }> | Ask the server which topics this connection is subscribed to |
unsubscribeAll() | none | Promise<{ 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
indicesis 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(notHNX-INDEX).UPCOMINDEXis sent asHNXUPCOMINDEXby the SDK. intervalaccepts onlytick,1mor5m(STREAM_INTERVALS). Omitintervalor usetickto get ticks. Any other value throwsValidationErrorlocally.Timeframe.MINUTE_1('1m') andTimeframe.MINUTE_5('5m') work. OtherTimeframevalues are rejected.- For
trade.index, an interval goes with one concrete code only, not*and not anA-Brange. - Each code and interval combination is a separate topic (
trade.index.VN30andtrade.index.VN30@1mare independent).
subscribeIndex vs subscribeIndexTrade
| Method | What 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 withErrors and notes
| Situation | Result |
|---|---|
interval is not tick/1m/5m, a badly formatted code (comma, odd characters), or an empty indices | ValidationError 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 connected | WebSocketError |
| The connection cannot be recovered | onConnection receives failed and lastError is set. A failed first connect() throws WebSocketError or AuthenticationError |
| A callback throws | The SDK logs it and still calls the remaining callbacks |
- The
@*behaviour for boards/indices and theindexsummarydata depend on the server and are not confirmed on a live server. If no data arrives, uselistSubscription()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:
| Field | Type | Description |
|---|---|---|
state | 'connected' | 'reconnected' | 'disconnected' | 'failed' | State |
reconnectCount | number | How many times the socket was re-opened after a drop |
error | Error? | 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
| Group | Method | Description |
|---|---|---|
| Connection | connect() | Connect WebSocket |
disconnect() | Disconnect (Promise<void>) | |
wait(timeoutMs?) | Wait until the connection ends for good or the time runs out | |
| Heartbeat | ping(onResponse?, intervalMs?) | Ping the server (optionally repeating) |
| Symbol | subscribeSymbol(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/Index | subscribeBoard(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 | |
| Market | subscribeMarketFlag() | Market session flag (needed for onMarketFlag) |
subscribeMarket(symbols) | Master data | |
| Trading | subscribeOrderStatus(accountNo = '*', onResponse?) | Order status (regular + FCO) |
subscribePortfolio(accountNo = '*', onResponse?) | Portfolio changes | |
subscribeFcoOrderStatus(accountNo?, onResponse?) | Deprecated, use subscribeOrderStatus | |
| Unsubscribe | unsubscribeSymbol(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 | |
| Query | listSubscription(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 fromAuth. Config.privateKeyis required for state-changing commands (place, amend, cancel, batch, FCO). Without it the SDK throwsWebSocketError. Queries and subscriptions are not signed.- Only
wss://is accepted. - It is separate from
TradingandStream: 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.
| Function | Parameters | Returns | Description |
|---|---|---|---|
asyncTradingWs(auth, options?) | auth: Auth, options: TradingWSClientOptions | Promise<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
| Method | Parameters | Returns | Command |
|---|---|---|---|
placeOrder | accountNo, symbol, side, quantity, price, orderType, clientRequestId? | PlaceOrderResponse | order.place |
amendOrder | accountNo, price?, quantity?, orderId?, clientRequestId? | ModifyOrderResponse | order.amend |
cancelOrder | accountNo, orderId?, clientRequestId? | CancelOrderResponse | order.cancel |
batchNewOrders | orders: readonly BatchPlaceOrderItem[] | TradingWSResponse | order.batchNew |
batchCancelOrders | orders: readonly BatchCancelOrderItem[] | TradingWSResponse | order.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)
| Method | Parameters | Returns | Command |
|---|---|---|---|
placeFco | order: FcoParamsKind ({ kind: 'gtd' | 'stop' | 'trailingStop' | 'oco' | 'bullBear', params }) | FCOPlaceResponse | fco.place |
cancelFco | fcoId, code? | FCOCancelResponse | fco.cancel |
listFco | accountNo, options?: FcoListOptions | TradingWSResponse | fco.list |
fcoOrderBook | fcoId | TradingWSResponse | fco.orderbook |
fcoStatusHistory | fcoId | TradingWSResponse | fco.statusHistory |
listFco takes a single processStatus, not an array. fcoOrderBook sends the defaults pageIndex=1, pageSize=10.
Queries
| Method | Parameters | Returns | Command |
|---|---|---|---|
queryPosition | accountNo, querySummary = true | TradingWSResponse | query.position |
queryMaxBuySell | accountNo, symbol, price? | TradingWSResponse | query.maxBuySell |
queryAccountBalance | accountNo | TradingWSResponse | query.accountBalance |
queryPpmmrAccount | accountNo | TradingWSResponse | query.ppmmrAccount |
queryOrderBook | accountNo, query?: OrderBookQuery | TradingWSResponse | query.orderBook |
queryMaxBuySell: omitpriceand the server uses its reference price.OrderBookQueryhasfromDate,toDate(YYYY/MM/DD),symbol,orderStatus,pageIndexandpageSize.
Events and utilities
| Method | Parameters | Description |
|---|---|---|
subscribeOrderEvents(accountNo) | string | Order events for one account (order.<account>) |
unsubscribeOrderEvents(accountNo) | string | Unsubscribe |
subscribePortfolioEvents(accountNo) | string | Portfolio events for one account (portfolio.<account>) |
unsubscribePortfolioEvents(accountNo) | string | Unsubscribe |
unsubscribeAllEvents() | none | Drop 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() | none | Close 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
| Error | When |
|---|---|
TradingWSError | The server returned an error frame, or refused the connection after accepting the socket. Has status, errorCode, serverMessage. Extends WebSocketError |
WebSocketError | Not 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 |
ValidationError | Bad 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. SoconnectwaitshandshakeGraceMs(500 ms) and throwsTradingWSErrorif that frame shows up. You can also read it throughws.client.rejection. - Signature refused: for error codes
401006,401008and401013insignatureEncoding: '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 thatConfig.privateKeymatches the API key. If every signed command fails, try settingsignatureEncodingexplicitly. - 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>) orqueryOrderBookbefore 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) | |
|---|---|---|
| Endpoint | Config.streamingUrl (default wss://stream.ssi.com.vn/ws/v3) | Config.tradingWsUrl (default wss://api.ssi.com.vn/ws/v3/trading) |
| Purpose | Real-time market data and order/portfolio events | Place/amend/cancel orders, FCO, account queries, order/portfolio events |
| Reconnect | Yes: reopens, refreshes the token, re-subscribes | No |
| Account subscription | * allowed | One account at a time |
| Event data | Typed 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.