API Services
Detailed API methods for each service in the .NET SDK
1. Authentication (TokenManager)
Access via auth.TokenManager or directly on auth (delegated).
Authenticate with OTP
var token = await auth.AuthenticateAsync("YOUR_OTP");
Console.WriteLine($"Expires at (epoch seconds): {token.ExpiresAt}");ExpiresAt / RefreshTokenExpiresAt are epoch seconds (0 = unknown, the SDK treats it as expired). Do not log AccessToken.
AuthenticateAsync(otp?, transactionId?) is a single call. For Smart OTP, pass transactionId (passing it together with otp throws AuthenticationException). While the user has not yet approved in the app, the SDK throws SmartOtpPendingException (HTTP 202 / code 401114).
Request OTP
OtpResponse otp = await auth.RequestOtpTypedAsync();
Console.WriteLine(otp.Message);
// otp.TransactionId (string?) is used by the Smart OTP flowRequestOtpAsync() still exists and returns a raw JsonElement; prefer RequestOtpTypedAsync().
Refresh token
var token = await auth.RefreshAsync();Ensure authenticated automatically
// Regular OTP
var accessToken = await auth.EnsureAuthenticatedAsync("YOUR_OTP");
// Smart OTP (approve in the app): polls using Config.OtpPollIntervalMs / OtpPollMaxWaitMs
var otp = await auth.RequestOtpTypedAsync();
await auth.EnsureAuthenticatedAsync(transactionId: otp.TransactionId);Order of handling: current token, refresh, OTP, then Smart OTP polling. Details and exceptions: Approve OTP.
2. Account (AccountService)
Access via trading.Account.
Get account list
var accounts = await trading.Account.GetAccountInfoAsync();
foreach (var acc in accounts)
{
Console.WriteLine($"{acc.AccountNo} - {acc.AccountType}");
}Returns: List<Account> — each Account has AccountNo: string, AccountType: string?.
3. Market Data (MarketDataService)
Access via data.MarketData. No OTP needed.
3.1. OHLC Data (Candlesticks)
Intraday:
| Method | Timeframe |
|---|---|
GetOhlc1MinuteAsync(symbol) | 1 minute |
GetOhlc3MinuteAsync(symbol) | 3 minutes |
GetOhlc5MinuteAsync(symbol) | 5 minutes |
GetOhlc15MinuteAsync(symbol) | 15 minutes |
GetOhlc1HourAsync(symbol) | 1 hour |
var ohlc = await data.MarketData.GetOhlc1MinuteAsync("SSI");
foreach (var candle in ohlc)
{
Console.WriteLine($"{candle.TradingDate}: O={candle.OpenPrice} H={candle.HighPrice} L={candle.LowPrice} C={candle.ClosePrice} V={candle.Volume}");
}Historical (with date range + pagination):
| Method | Timeframe |
|---|---|
GetOhlc1MinuteHistoricalAsync(symbol, fromDate, toDate, page, size) | 1 minute |
GetOhlc3MinuteHistoricalAsync(...) | 3 minutes |
GetOhlc5MinuteHistoricalAsync(...) | 5 minutes |
GetOhlc15MinuteHistoricalAsync(...) | 15 minutes |
GetOhlc1HourHistoricalAsync(...) | 1 hour |
GetOhlc1DayHistoricalAsync(...) | 1 day |
var ohlc = await data.MarketData.GetOhlc1DayHistoricalAsync(
symbol: "SSI",
fromDate: "2026/03/27",
toDate: "2026/04/22",
page: 1,
size: 100);Historical parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
symbol | string | (required) | Ticker symbol |
fromDate | string | (required) | Start date |
toDate | string | (required) | End date |
page | int | 1 | Page number |
size | int | 1000 | Records per page |
Returns: List<OhlcData>
Date conventions: a date-only fromDate gets 00:00:00 and a date-only toDate gets 23:59:59. For timeframes other than 1d, a toDate in the future is clamped to today 23:59:59. Passing no dates means today.
Deprecated:
GetOhlc1WeekHistoricalAsyncandGetOhlc1MonthHistoricalAsyncare marked[Obsolete]and always throwValidationException(code 400210).Timeframe.Allowedcontains1d,1m,3m,5m,15m,1h.
Iterate all pages:
// IAsyncEnumerable<OhlcData>, pages automatically (default size 1000)
await foreach (var c in data.MarketData.IterOhlcAsync("SSI", Timeframe.Minute5, "2026/03/01", "2026/03/31"))
{
Console.WriteLine($"{c.TradingDate} C={c.ClosePrice}");
}
// Collect into a List (ascending = false by default)
List<OhlcData> all = await data.MarketData.GetOhlcAllAsync("SSI", Timeframe.Day1, "2026/03/01", "2026/03/31");3.1b. Download OHLC
The DownloadOhlc*Async methods return Task<List<OhlcData>> but are not supported yet: calling them throws NotImplementedException. Use GetOhlc*HistoricalAsync or IterOhlcAsync.
3.2. Market Index List
// All indexes
var indexes = await data.MarketData.GetIndexesAsync();
// By board
var indexesByBoard = await data.MarketData.GetIndexesByBoardAsync(Board.HOSE);
foreach (var idx in indexes)
{
Console.WriteLine($"{idx.Index} - {idx.IndexName}");
}Returns: List<MarketIndexes>
3.3. Index Summary
// Current summary
var summary = await data.MarketData.GetIndexSummaryAsync("VNINDEX");
// Historical summary
var summaryHist = await data.MarketData.GetIndexSummaryHistoricalAsync("VNINDEX", "2026/01/15");
// Board summary
var boardSummary = await data.MarketData.GetBoardSummaryAsync(Board.HOSE);
// Historical board summary
var boardSummaryHist = await data.MarketData.GetBoardSummaryHistoricalAsync(Board.HOSE, "2026/01/15");Returns: MarketIndexSummary?
3.4. Securities Info
// Single symbol
var info = await data.MarketData.GetSecuritiesInfoAsync("SSI");
// By index
var byIndex = await data.MarketData.GetSecuritiesInfoByIndexAsync("VN30");
// By board
var byBoard = await data.MarketData.GetSecuritiesInfoByBoardAsync(Board.HOSE);Returns: SecuritiesInfo? / List<SecuritiesInfo>
3.5. Securities Summary
// Current summary
var summary = await data.MarketData.GetSecuritiesSummaryAsync("SSI");
// Historical summary
var summaryHist = await data.MarketData.GetSecuritiesSummaryHistoricalAsync(
symbol: "SSI", fromDate: "2026/03/01", toDate: "2026/03/31");
// By index
var byIndex = await data.MarketData.GetSecuritiesSummaryByIndexAsync("VN30");
// By index, historical
var byIndexHist = await data.MarketData.GetSecuritiesSummaryByIndexHistoricalAsync(
index: "VN30", fromDate: "2026/03/01", toDate: "2026/03/31");Returns: List<SecuritiesSummary> (the SDK pages automatically for the summary methods).
3.6. Master Data
// Today
List<MasterData> today = await data.MarketData.GetMasterDataAsync();
// Historical
List<MasterData> hist = await data.MarketData.GetMasterDataHistoricalAsync("2026/03/01", "2026/03/31");Returns: List<MasterData> (auto-paged).
3.7. Corporate Actions
Corporate actions of one symbol or of the whole market: dividends, bonus shares, rights issues, and so on. There are two methods, split by time. Both fetch every page (pageSize 100) and return List<CorporateAction>.
// Today (all symbols, or one symbol)
List<CorporateAction> today = await data.MarketData.GetCorporateActionsAsync();
List<CorporateAction> ssi = await data.MarketData.GetCorporateActionsAsync("SSI", "en");
// Past: a date range of at most 5 days
List<CorporateAction> past = await data.MarketData.GetCorporateActionsHistoricalAsync(
fromDate: "2026/10/08", toDate: "2026/10/12",
symbol: null, // null = all symbols
language: "vn"); // "vn" (default) or "en"
foreach (var e in past)
{
Console.WriteLine($"{e.Symbol} {e.EventTitle} {e.ExrightDate} {e.RecordDate} {e.IssueDate} {e.Exchange} {e.Ratio}");
}| Method | Parameters | Returns | Description |
|---|---|---|---|
GetCorporateActionsAsync | string? symbol = null, string language = "vn", ct | Task<List<CorporateAction>> | Today's corporate actions |
GetCorporateActionsHistoricalAsync | string fromDate, string toDate, string? symbol = null, string language = "vn", ct | Task<List<CorporateAction>> | Corporate actions in the range fromDate to toDate |
The SDK checks the arguments before any call. A bad value throws ValidationException and nothing is sent:
fromDate/toDatemust be formattedYYYY/MM/DD,fromDatemust not be aftertoDate, and the two dates must be at most 5 days apart.symbol(if given) is a single alphanumeric symbol of at most 20 characters.nullmeans all symbols.languageisvnoren.
For the fields of CorporateAction see Models.
Note: these two methods were written from the official API documentation and have not been run against the live server (per the SDK 3.3.2 changelog). Check the results yourself before relying on them in a critical flow.
4. Portfolio (PortfolioService)
Access via trading.Portfolio.
4.1. Account Balance
// Equity balance
var equityBalance = await trading.Portfolio.GetEquityBalanceAsync("YOUR_ACCOUNT_NO");
Console.WriteLine($"Available cash: {equityBalance?.AccountBalance}");
// Derivative balance
var derivativeBalance = await trading.Portfolio.GetDerivativeBalanceAsync("YOUR_ACCOUNT_NO");
Console.WriteLine($"Withdrawable: {derivativeBalance?.Withdrawable}");4.2. Positions
// Equity positions
var positions = await trading.Portfolio.GetEquityPositionsAsync("YOUR_ACCOUNT_NO");
foreach (var pos in positions ?? new List<EquityPosition>()) // may return null
{
Console.WriteLine($"{pos.Symbol}: {pos.Quantity} shares | Cost: {pos.CostPrice}");
}
// All derivative positions (split into Open/Closed)
var allDerivative = await trading.Portfolio.GetDerivativePositionsAsync("YOUR_ACCOUNT_NO");
// Open derivative positions only
var openPos = await trading.Portfolio.GetOpenDerivativePositionsAsync("YOUR_ACCOUNT_NO");
// Closed derivative positions only
var closedPos = await trading.Portfolio.GetClosedDerivativePositionsAsync("YOUR_ACCOUNT_NO");4.3. Order Book
// Today's orders
var todayOrders = await trading.Portfolio.GetTodayOrdersAsync("YOUR_ACCOUNT_NO");
foreach (var order in todayOrders)
{
Console.WriteLine($"{order.OrderId}: {order.Symbol} {order.Side} {order.Quantity}@{order.Price} - {order.Status}");
}
// Historical orders
var historicalOrders = await trading.Portfolio.GetHistoricalOrdersAsync(
"YOUR_ACCOUNT_NO", fromDate: "2026/01/01", toDate: "2026/01/31");4.4. PPMMR
// Equity PPMMR
var equityPpmmr = await trading.Portfolio.GetEquityPpmmrAsync("YOUR_ACCOUNT_NO");
Console.WriteLine($"Purchasing power: {equityPpmmr?.PurchasingPower}");
// Derivative PPMMR
var derivativePpmmr = await trading.Portfolio.GetDerivativePpmmrAsync("YOUR_ACCOUNT_NO");Portfolio Method Overview
| Group | Method | Returns |
|---|---|---|
| Balance | GetEquityBalanceAsync(accountNo) | EquityAccountBalance? |
GetDerivativeBalanceAsync(accountNo) | DerivativeAccountBalance? | |
| Positions | GetEquityPositionsAsync(accountNo) | List<EquityPosition>? |
GetDerivativePositionsAsync(accountNo) | AllDerivativePosition? | |
GetOpenDerivativePositionsAsync(accountNo) | List<DerivativePosition> | |
GetClosedDerivativePositionsAsync(accountNo) | List<DerivativePosition> | |
| Order book | GetTodayOrdersAsync(accountNo) | List<Order> |
GetHistoricalOrdersAsync(accountNo, fromDate, toDate) | List<Order> | |
| PPMMR | GetEquityPpmmrAsync(accountNo) | EquityPPMMR? |
GetDerivativePpmmrAsync(accountNo) | DerivativePPMMR? |
5. Trading (TradingService)
Access via trading.Trading.
5.1. Place Orders
price is decimal and quantity is int. clientRequestId is an optional trailing parameter: at most 20 characters, generated by the SDK when null. Every POST/PUT/DELETE is signed with PrivateKey.
Controls applied to the trading examples: account numbers are placeholders,
PrivateKey/OTP come from environment variables or manual input (never hardcoded), the SDK validatesclientRequestId(max 20 characters) before sending, and write requests are never retried automatically.
// Limit order (LO)
var result = await trading.Trading.PlaceLimitOrderAsync(
accountNo: "YOUR_ACCOUNT_NO", symbol: "SSI", side: OrderSide.Buy, quantity: 100, price: 66000);
Console.WriteLine($"Order ID: {result.OrderId}, Status: {result.Status}");
// Market order (MTL)
var market = await trading.Trading.PlaceMarketOrderAsync(
"YOUR_ACCOUNT_NO", "SSI", OrderSide.Buy, 100);
// ATO order (opening)
var ato = await trading.Trading.PlaceAtoOrderAsync(
"YOUR_ACCOUNT_NO", "SSI", OrderSide.Buy, 100);
// ATC order (closing)
var atc = await trading.Trading.PlaceAtcOrderAsync(
"YOUR_ACCOUNT_NO", "SSI", OrderSide.Sell, 100);
// Custom order type
var custom = await trading.Trading.PlaceOrderAsync(
"YOUR_ACCOUNT_NO", "SSI", OrderSide.Buy, 100, 66000, OrderType.LO);5.2. Modify Orders
Only price or quantity can be modified (not both at once).
// Modify price by ClientRequestId
var r1 = await trading.Trading.ModifyOrderPriceAsync("YOUR_ACCOUNT_NO", "REQ123", 68000);
// Modify price by OrderId
var r2 = await trading.Trading.ModifyOrderPriceByOrderIdAsync("YOUR_ACCOUNT_NO", "ORD123", 68000);
// Modify quantity by ClientRequestId
var r3 = await trading.Trading.ModifyOrderQuantityAsync("YOUR_ACCOUNT_NO", "REQ123", 200);
// Modify quantity by OrderId
var r4 = await trading.Trading.ModifyOrderQuantityByOrderIdAsync("YOUR_ACCOUNT_NO", "ORD123", 200);5.3. Cancel Orders
// Cancel by ClientRequestId
var c1 = await trading.Trading.CancelOrderAsync("YOUR_ACCOUNT_NO", "REQ123");
// Cancel by OrderId
var c2 = await trading.Trading.CancelOrderByOrderIdAsync("YOUR_ACCOUNT_NO", "ORD123");5.4. Max Buy/Sell
// With a specific price
var maxBS = await trading.Trading.GetMaxBuySellAsync("YOUR_ACCOUNT_NO", "SSI", 66000);
Console.WriteLine($"Max buy: {maxBS.MaxBuyQuantity}");
Console.WriteLine($"Max sell: {maxBS.MaxSellQuantity}");
// At market price
var maxBSMarket = await trading.Trading.GetMaxBuySellAtMarketPriceAsync("YOUR_ACCOUNT_NO", "SSI");Trading Method Overview
| Group | Method | Returns |
|---|---|---|
| Place order | PlaceOrderAsync(accountNo, symbol, side, quantity, price?, orderType, clientRequestId?) | PlaceOrderResponse |
PlaceLimitOrderAsync(accountNo, symbol, side, quantity, price, clientRequestId?) | PlaceOrderResponse | |
PlaceMarketOrderAsync(accountNo, symbol, side, quantity, clientRequestId?) | PlaceOrderResponse | |
PlaceAtoOrderAsync(accountNo, symbol, side, quantity, clientRequestId?) | PlaceOrderResponse | |
PlaceAtcOrderAsync(accountNo, symbol, side, quantity, clientRequestId?) | PlaceOrderResponse | |
| Batch orders | PlaceBatchOrdersAsync(orders) | BatchOrderResponse |
CancelBatchOrdersAsync(orders) | BatchOrderResponse | |
| Modify order | ModifyOrderPriceAsync(accountNo, clientRequestId, price) | ModifyOrderResponse |
ModifyOrderPriceByOrderIdAsync(accountNo, orderId, price) | ModifyOrderResponse | |
ModifyOrderQuantityAsync(accountNo, clientRequestId, quantity) | ModifyOrderResponse | |
ModifyOrderQuantityByOrderIdAsync(accountNo, orderId, quantity) | ModifyOrderResponse | |
| Cancel order | CancelOrderAsync(accountNo, clientRequestId) | CancelOrderResponse |
CancelOrderByOrderIdAsync(accountNo, orderId) | CancelOrderResponse | |
| Max buy/sell | GetMaxBuySellAsync(accountNo, symbol, price) | MaxBuySellResponse |
GetMaxBuySellAtMarketPriceAsync(accountNo, symbol) | MaxBuySellResponse | |
| Conditional Orders | PlaceFcoGtdAsync(accountNo, symbol, side, quantity, price, priceSlip, fromDate, toDate, code?) | FCOPlaceResponse |
PlaceFcoStopAsync(accountNo, symbol, side, quantity, stopPrice, operatorType, fromDate, toDate, code?) | FCOPlaceResponse | |
PlaceFcoStopLimitAsync(accountNo, symbol, side, quantity, price, priceSlip, stopPrice, operatorType, fromDate, toDate, code?) | FCOPlaceResponse | |
PlaceFcoTrailingStopAsync(accountNo, symbol, side, quantity, activePrice, trailingAmount, fromDate, toDate, code?) | FCOPlaceResponse | |
PlaceFcoTrailingStopLimitAsync(accountNo, symbol, side, quantity, activePrice, trailingAmount, priceSlip, fromDate, toDate, code?) | FCOPlaceResponse | |
PlaceFcoOcoAsync(accountNo, symbol, side, quantity, tpActivePrice, slActivePrice, tpPrice, slPrice, tpSlip, slSlip, fromDate, toDate, code?) | FCOPlaceResponse | |
PlaceFcoBullBearAsync(accountNo, symbol, side, quantity, price, priceSlip, tpActivePrice, slActivePrice, tpPrice, slPrice, tpSlip, slSlip, fromDate, toDate, code?) | FCOPlaceResponse | |
CancelFcoAsync(fcoId, code?) | FCOCancelResponse | |
GetFcoByAccountNoAsync(accountNo, pageIndex?, pageSize?) | FCOListResponse | |
GetFcoBySymbolAsync(accountNo, symbol, pageIndex?, pageSize?) | FCOListResponse | |
GetFcoByStatusAsync(accountNo, processStatus, pageIndex?, pageSize?) | FCOListResponse | |
GetFcoByDateAsync(accountNo, fromDate, toDate, pageIndex?, pageSize?) | FCOListResponse | |
GetFcoByIdAsync(accountNo, fcoId) | FCOInfo? | |
GetFcoOrderBookAsync(fcoId, pageIndex = 1, pageSize = 10) | FCOOrderBookResponse | |
GetFcoStatusHistoryAsync(fcoId) | List<FCOStatusHistoryItem> |
5.5. Conditional Orders (FCO)
Supports flexible conditional order types: GTD, Stop, Stop Limit, Trailing Stop, Trailing Stop Limit, OCO, and Bull Bear. These orders are automatically signed the same way as regular orders.
using SsiSdk;
string accountNo = "YOUR_ACCOUNT_NO";
string fromDate = "2026/07/21 00:00:00";
string toDate = "2026/07/28 23:00:00";
// GTD order (Good Till Date)
var gtd = await trading.Trading.PlaceFcoGtdAsync(
accountNo, "SSI", OrderSide.Buy, 100, 25000, 0.5m, fromDate, toDate);
// Stop order (stop market)
var stop = await trading.Trading.PlaceFcoStopAsync(
accountNo, "SSI", OrderSide.Sell, 100, 24000m, FCOOperator.LesserOrEqual, fromDate, toDate);
// Stop Limit order
var stopLimit = await trading.Trading.PlaceFcoStopLimitAsync(
accountNo, "SSI", OrderSide.Buy, 100, 25500, 0.5m, 25000m, FCOOperator.GreaterOrEqual, fromDate, toDate);
// Trailing Stop order
var trailing = await trading.Trading.PlaceFcoTrailingStopAsync(
accountNo, "SSI", OrderSide.Buy, 100, 26000m, 1000m, fromDate, toDate);
// Trailing Stop Limit order
var trailingLimit = await trading.Trading.PlaceFcoTrailingStopLimitAsync(
accountNo, "SSI", OrderSide.Buy, 100, 26000m, 1000m, 0.5m, fromDate, toDate);
// OCO order (one cancels the other)
var oco = await trading.Trading.PlaceFcoOcoAsync(
accountNo, "SSI", OrderSide.Sell, 100, 30000m, 24000m, OrderType.MTL, OrderType.MTL, 0.5m, 0.5m, fromDate, toDate);
// Bull Bear order
var bullBear = await trading.Trading.PlaceFcoBullBearAsync(
accountNo, "SSI", OrderSide.Buy, 1000, 100.5m, 0.5m, 30m, 20m, 30, 10, 0.1m, 0m, fromDate, toDate);
// Cancel a conditional order
var cancelRes = await trading.Trading.CancelFcoAsync(gtd.FCOID);Date rules for placing FCO orders: format yyyy/MM/dd HH:mm:ss, fromDate <= toDate, window of at most 31 days (Constants.MaxFcoWindowDays). The price / tpPrice / slPrice parameters are of type FcoPrice (implicitly convertible from decimal, int, long or one of the strings ATO/ATC/MTL/MP/MOK/MAK; these market words do not use priceSlip). Every PlaceFco* method and CancelFcoAsync takes an optional code.
Returns: FCOPlaceResponse (FCOID) when placing an order, FCOCancelResponse (FCOID) when cancelling.
// Get all FCO orders for an account
var fcos = await trading.Trading.GetFcoByAccountNoAsync("YOUR_ACCOUNT_NO");
// Get FCO orders by symbol
var fcosBySymbol = await trading.Trading.GetFcoBySymbolAsync("YOUR_ACCOUNT_NO", "SSI");
// Get FCO orders by processing status
var fcosByStatus = await trading.Trading.GetFcoByStatusAsync("YOUR_ACCOUNT_NO", FCOStatus.Trit);
// Get FCO orders by date range
var fcosByDate = await trading.Trading.GetFcoByDateAsync("YOUR_ACCOUNT_NO", "2026/07/01", "2026/07/21");
// Get a single FCO order by ID
var fcoInfo = await trading.Trading.GetFcoByIdAsync("YOUR_ACCOUNT_NO", "fco-id-here");
// Get the order book / execution log for an FCO order
var orderBook = await trading.Trading.GetFcoOrderBookAsync("fco-id-here");
// Status history of an FCO order
List<FCOStatusHistoryItem> history = await trading.Trading.GetFcoStatusHistoryAsync("fco-id-here");Note: The list-query methods (
GetFcoByAccountNoAsync,GetFcoBySymbolAsync,GetFcoByStatusAsync,GetFcoByDateAsync) takepageIndexandpageSizeasint?:nullmeans the server default (the SDK sets no default itself). OnlyGetFcoOrderBookAsyncdefaults topageIndex = 1,pageSize = 10.GetFcoByStatusAsyncalso has an overload taking anIReadOnlyCollection<string>to filter by several statuses. It is not confirmed whetherGetFcoByDateAsyncrequires the time part; the example above uses dates only.
5.6. Batch Orders
PlaceBatchOrdersAsync and CancelBatchOrdersAsync send many orders in one signed REST request: POST /order/batch to place, DELETE /order/batch to cancel (full path /api/v3/trading/order/batch). Each order gets its own result in the response. Use them to place/cancel many orders at once (portfolio rebalancing, cancelling a basket) with one signature, one round trip and one rate-limit hit instead of N. For a single order use PlaceOrderAsync / CancelOrderAsync (5.1, 5.3).
Warning: the batch methods place and cancel real orders. Try them on a test account and review the batch content before sending.
Prerequisites: Config has ApiKey, ApiSecret, PrivateKey (every batch request is signed with RSA PKCS#1 v1.5 + SHA-256); AuthClient is authenticated (Client Classes); the API Key is allowed to place orders (trading:order:post per the API reference) on every account in the batch.
| Method | Parameters | Returns | Description |
|---|---|---|---|
PlaceBatchOrdersAsync | IReadOnlyCollection<BatchPlaceOrderItem> orders, ct | Task<BatchOrderResponse> | Place 1 to Config.MaxBatchOrders orders (POST /order/batch) |
CancelBatchOrdersAsync | IReadOnlyCollection<BatchCancelOrderItem> orders, ct | Task<BatchOrderResponse> | Cancel 1 to Config.MaxBatchOrders orders (DELETE /order/batch) |
For the item and response structures (BatchPlaceOrderItem, BatchCancelOrderItem, BatchOrderResponse, BatchOrderResult) see Batch Order Models.
Batch size limit. Config.MaxBatchOrders defaults to 20 (values below 1 are reset to 20). The limit is checked before sending: an empty or oversized batch throws ValidationException and no request is sent. Batches over the Trading WebSocket (section 7) use the same limit.
Note (not reconciled): the API reference (
trade-order-batch-place) states a server-side limit of 50 orders per request, while the SDK default is 20. RaisingConfig.MaxBatchOrdersis the caller's decision; follow the limit that applies to your API Key.
How it works
- Validate first. The SDK validates every order (quantity, price,
ClientRequestIdlength, cancel identifiers) before building the body. One invalid order throwsValidationExceptionand rejects the whole batch on the client. Each cancel item needs exactly one ofOrderIdorClientRequestId. - One signature for the whole body. The SDK serializes the body once, signs exactly that string and sends the same string with the signature in the
X-Signatureheader. You do not sign anything yourself. - Batch envelope. The body contains
batchRequestId,batchRequestTime(epoch milliseconds) andorders. The SDK generates a newbatchRequestIdon every call. Each order carriesdeviceId(the OS machine id, not settable inConfig) anduserAgent(Config.UserAgent, otherwiseSSI .NET SDK/<version>). - Per-order results. Once the batch passes validation, orders are processed independently: an order with
Success = false(for example insufficient buying power) does not affect the others. Always check every element ofResults. - No automatic retry.
POSTandDELETEare never retried after a timeout or a 429, because a repeated order could be created twice.
Idempotency. The SDK creates a new batchRequestId per call, so calling PlaceBatchOrdersAsync twice with the same list is two different batches as far as the SDK is concerned. What identifies an order across attempts is the ClientRequestId you set: assign a stable id to each order (max 20 characters), keep it, and reuse it when retrying or cancelling. If the server detects a duplicate, the SDK throws DuplicateRequestException (HTTP 409).
Note: the API reference describes
batchRequestIdas the batch idempotency key (unique per API Key, per place/cancel action, per day). The SDK does not let you pass this value, so rely onClientRequestId.
Controls applied to the batch examples: keys/OTP come from environment variables or manual input, the account is a placeholder, the SDK validates the whole batch before sending,
ClientRequestIdis stable to prevent duplicate orders, and POST/DELETE are not retried automatically.
Example 1: place a batch and read each result
using SsiSdk;
using SsiSdk.Models;
var runId = Guid.NewGuid().ToString("N")[..8];
var items = new List<BatchPlaceOrderItem>
{
new("YOUR_ACCOUNT_NO", "SSI", OrderSide.Buy, 100, 25000m, OrderType.LO, ClientRequestId: $"b-{runId}-1"),
new("YOUR_ACCOUNT_NO", "VNM", OrderSide.Buy, 100, 60000m, OrderType.LO, ClientRequestId: $"b-{runId}-2"),
};
try
{
var response = await trading.Trading.PlaceBatchOrdersAsync(items);
foreach (var r in response.Results)
{
Console.WriteLine(r.Success
? $"OK {r.ClientRequestId} -> OrderId={r.OrderId} Status={r.Status}"
: $"FAIL {r.ClientRequestId}: {r.ErrorCode} {r.ErrorMessage}");
}
}
catch (ValidationException ex)
{
// Empty batch, over the limit, or an invalid order: nothing was sent
Console.WriteLine($"SDK rejected: {ex.Message}");
}
catch (DuplicateRequestException ex)
{
Console.WriteLine($"Duplicate (409): {ex.Message}");
}
catch (RateLimitException ex)
{
// The SDK does not resend POST; wait and decide yourself
Console.WriteLine($"Rate limited, retry after {ex.RetryAfterMs} ms");
}
catch (AuthenticationException ex)
{
Console.WriteLine($"Authentication/signature error: {ex.Message}");
}
catch (ApiException ex)
{
Console.WriteLine($"API error [{ex.Code}] HTTP {ex.StatusCode}: {ex.Message}");
}DuplicateRequestException derives from ApiException, so catch it before ApiException.
Example 2: cancel orders from a place result
static async Task CancelPlacedAsync(TradingClient trading, BatchOrderResponse placed)
{
// Prefer OrderId; otherwise use ClientRequestId. Exactly one identifier per item.
var cancelItems = placed.Results
.Where(r => r.Success)
.Select(r => !string.IsNullOrEmpty(r.OrderId)
? new BatchCancelOrderItem("YOUR_ACCOUNT_NO", OrderId: r.OrderId)
: new BatchCancelOrderItem("YOUR_ACCOUNT_NO", ClientRequestId: r.ClientRequestId))
.ToList();
if (cancelItems.Count == 0) return;
var cancelResponse = await trading.Trading.CancelBatchOrdersAsync(cancelItems);
foreach (var r in cancelResponse.Results)
Console.WriteLine($"{(r.Success ? "OK " : "FAIL")} {r.ClientRequestId} {r.ErrorCode} {r.ErrorMessage}");
}Example 3: split a large list into several batches
// allItems: List<BatchPlaceOrderItem> built as in example 1
var limit = config.MaxBatchOrders;
foreach (var chunk in allItems.Chunk(limit))
{
// Send one after another; let an exception stop the loop instead of blindly sending the rest.
var resp = await trading.Trading.PlaceBatchOrdersAsync(chunk);
var failed = resp.Results.Count(r => !r.Success);
Console.WriteLine($"batch done: {resp.Results.Count} orders, {failed} failed");
}After a timeout on a POST, the outcome is unknown: check the order book (for example trading.Portfolio.GetTodayOrdersAsync) by ClientRequestId before resending.
Batch error handling
| Situation | SDK throws | Notes |
|---|---|---|
Empty batch, over MaxBatchOrders, invalid order, cancel item with 0 or 2 identifiers | ValidationException | Nothing sent |
| Missing or wrong signature | AuthenticationException (HTTP 401/403) | Codes such as 401006, 401008, 401013 (see ServerErrorCode). Check PrivateKey. |
| Rate limit (HTTP 429) | RateLimitException | Has RetryAfterMs, Limit, Remaining, Reset. No automatic wait for POST/DELETE. |
| Duplicate (HTTP 409) | DuplicateRequestException | Derives from ApiException |
| Batch-level server error (HTTP 4xx/5xx) | ApiException | Code, StatusCode, ResponseBody. The whole batch is rejected. |
| Timeout on POST/DELETE | The timeout exception propagates | Outcome unknown, do not resend blindly |
| One order rejected | No exception | Success = false with ErrorCode / ErrorMessage in Results |
The error-code appendix lists batch-level codes such as 400108 (empty or too many orders), 400109 (missing batchRequestId / batchRequestTime), 400111 (batchRequestTime outside the replay window), 403101 (account not permitted, whole batch rejected) and 409. Some codes share a number with single-order errors: use the endpoint you called and the message text to tell them apart. See Error Codes.
Step-by-step guide: Tutorial 14: Batch place/cancel orders.
6. Realtime Streaming (StreamingService)
Access via stream.Streaming. Call stream.ConnectAsync() first.
6.1. Connection
using var stream = new StreamClient(auth);
await stream.ConnectAsync(); // Connect WebSocket
// Wait indefinitely
await stream.WaitAsync();
// Wait with timeout (milliseconds)
await stream.WaitAsync(timeoutMs: 30000);
await stream.DisconnectAsync(); // Manual disconnect (if not using `using`); a synchronous Disconnect() also existsThe socket reconnects automatically after a drop and re-sends the recorded subscriptions. Track the state through OnConnection (a ConnectionEvent whose State is connected, reconnected, disconnected or failed, with ReconnectCount and Error), or stream.IsConnected, stream.LastError, stream.ReconnectCount.
6.2. Setting Callbacks
using SsiSdk.Models;
stream.Streaming.SetOnData(msg =>
{
switch (msg)
{
case TradeMessage t: Console.WriteLine($"Trade: {t.Symbol} {t.Price}"); break;
case QuoteMessage q: Console.WriteLine($"Quote: {q.Symbol}"); break;
}
});
stream.Streaming.SetOnTrading(msg =>
{
if (msg is OrderStatusMessage order)
Console.WriteLine($"Order: {order.Symbol} {order.Status}");
});
stream.Streaming.SetOnHeartbeat(hb => Console.WriteLine($"Heartbeat: {hb.Status}"));Set handlers before subscribing so you do not miss messages. Besides SetOn*, you can assign the properties directly: OnData, OnTrading (Action<object>), OnHeartbeat (HeartbeatMessage), OnMarketFlag (MarketFlagMessage), OnIndexSummary (IndexSummaryMessage), OnOrder (OrderStatusMessage), OnOrderMatch (OrderMatchMessage), OnPortfolio (PortfolioMessage), OnConnection (ConnectionEvent). If a callback throws, the SDK swallows the exception, logs only the exception type name and keeps the connection.
6.3. Subscribe to Market Data
// Individual channels
await stream.Streaming.SubscribeSymbolTradeAsync(["SSI", "HPG"]);
await stream.Streaming.SubscribeSymbolQuoteAsync(["SSI", "HPG"]);
await stream.Streaming.SubscribeSymbolRoomAsync(["SSI", "HPG"]);
await stream.Streaming.SubscribeSymbolPutThroughAsync(["SSI"]);
await stream.Streaming.SubscribeSymbolOddLotAsync(["SSI"]);
// All channels (trade + quote + room) for a symbol
await stream.Streaming.SubscribeSymbolAsync(["SSI", "HPG", "VIC"]);
// OHLCV by interval
await stream.Streaming.SubscribeSymbolOhlcvAsync(["SSI"], Timeframe.Minute1);
// By board (@* fan-out, see the note below)
await stream.Streaming.SubscribeBoardAsync([Board.HOSE, Board.HNX]);
// By index (@* fan-out of the constituents, see the note below)
await stream.Streaming.SubscribeIndexAsync(["VNINDEX", "VN30"]);
// Master data (ceiling, floor, reference) of a symbol or board
await stream.Streaming.SubscribeMarketAsync(["SSI"]);
// Trading-session flag (ATO, LO, ATC, ...) of the boards
await stream.Streaming.SubscribeMarketFlagAsync();
// Ticks, candles and summary of the index itself: see 6.7Note (unconfirmed):
SubscribeBoardAsyncandSubscribeIndexAsyncsend topics of the formtrade.CODE@*,quote.CODE@*,room.CODE@*. The@*fan-out semantics have not been confirmed against a live server; verify in practice before relying on it. Both can produce a very large number of messages.
6.4. Unsubscribe
await stream.Streaming.UnsubscribeSymbolTradeAsync(["SSI"]);
await stream.Streaming.UnsubscribeSymbolQuoteAsync(["SSI"]);
await stream.Streaming.UnsubscribeSymbolRoomAsync(["SSI"]);
await stream.Streaming.UnsubscribeSymbolPutThroughAsync(["SSI"]);
await stream.Streaming.UnsubscribeSymbolOddLotAsync(["SSI"]);
await stream.Streaming.UnsubscribeSymbolAsync(["SSI", "HPG"]);
// Unsubscribe OHLCV
await stream.Streaming.UnsubscribeSymbolOhlcvAsync(["SSI"], Timeframe.Minute1);
await stream.Streaming.UnsubscribeBoardAsync([Board.HOSE]);
await stream.Streaming.UnsubscribeIndexAsync(["VNINDEX"]);
await stream.Streaming.UnsubscribeMarketAsync(["SSI"]);
await stream.Streaming.UnsubscribeMarketFlagAsync();
// Unsubscribe everything and see what the server still holds
var removed = await stream.Streaming.UnsubscribeAllAsync(); // Dictionary<string, List<string>>
var active = await stream.Streaming.ListSubscriptionAsync(timeoutMs: 5000); // keys "trading" and "data"6.5. Subscribe to Trading Events
// Order status (all accounts, default "*")
await stream.Streaming.SubscribeOrderStatusAsync();
// For a specific account
await stream.Streaming.SubscribeOrderStatusAsync("YOUR_ACCOUNT_NO");
// Portfolio
await stream.Streaming.SubscribePortfolioAsync();
await stream.Streaming.SubscribePortfolioAsync("YOUR_ACCOUNT_NO");6.6. Heartbeat
// One-time ping
await stream.Streaming.PingAsync();
// Automatic recurring ping (milliseconds)
await stream.Streaming.PingAsync(intervalMs: 30000);
// Stop the automatic ping loop
stream.Streaming.StopPingLoop();Message Types Received via SetOnData
| Topic | Message Type | Description |
|---|---|---|
trade.* | TradeMessage | Matched trade data |
trade.*.<timeframe> | IntervalMessage | OHLCV data by interval |
quote.* | QuoteMessage | Bid/ask data |
room.* | ForeignRoomMessage | Foreign room |
trade.index.* | IndexTickMessage (or IntervalMessage with @1m/@5m) | Index tick / candle (see 6.7) |
indexsummary.* | IndexSummaryMessage | Index summary (see 6.7) |
market.flag | MarketFlagMessage | Trading-session flag |
market.* | MarketDataMessage (if it has s/ce/fl/ref), otherwise MarketStatusMessage | Master data / market status |
put.* | PutMessage | Put-through deals |
oddlot.* | OddLotMessage | Odd lot |
A message that matches none of the above is kept as a JsonElement.
Message Types Received via SetOnTrading
The SDK picks the type from the message's eventType field:
eventType | Message Type | Description |
|---|---|---|
orderEvent | OrderStatusMessage | Order status |
orderMatchEvent | OrderMatchMessage | Order match |
clientPortfolioEvent | PortfolioMessage | Portfolio changes |
fcoEvent | FCOOrderUpdateMessage | Conditional (FCO) order update |
If eventType is empty, the topic prefix order. / portfolio. decides the type. There is no dedicated handler for fcoEvent: receive it via OnTrading/SetOnTrading. A message that does not match is kept as a JsonElement.
Note (unconfirmed): the SDK code parses
eventType == "fcoEvent"intoFCOOrderUpdateMessage, but the SDK CHANGELOG listsfcoEventstreaming as not implemented. Not verified against a live server; do not rely on this stream for critical logic.
Streaming Method Overview
| Group | Method | Description |
|---|---|---|
| Connection | ConnectAsync() | Connect WebSocket |
DisconnectAsync() / Disconnect() | Disconnect | |
WaitAsync(timeoutMs?) | Wait for data | |
IsConnected, LastError, ReconnectCount | Connection state | |
OnConnection | ConnectionEvent callback | |
| Heartbeat | PingAsync(intervalMs?) | Ping server (optionally recurring) |
StopPingLoop() | Stop the automatic ping loop | |
| Symbol subscribe | SubscribeSymbolAsync(symbols) | Trade + Quote + Room |
SubscribeSymbolTradeAsync(symbols) | Trade only | |
SubscribeSymbolQuoteAsync(symbols) | Quote only | |
SubscribeSymbolRoomAsync(symbols) | Foreign room only | |
SubscribeSymbolPutThroughAsync(symbols) | Put-through only | |
SubscribeSymbolOddLotAsync(symbols) | Odd lot only | |
SubscribeSymbolOhlcvAsync(symbols, interval) | OHLCV by timeframe | |
| Board/Index subscribe | SubscribeBoardAsync(boards) | By board (@* fan-out, unconfirmed) |
SubscribeIndexAsync(indices) | Constituents of an index (@* fan-out, unconfirmed) | |
SubscribeIndexTradeAsync(indices, interval?) | Ticks / candles of the index itself | |
SubscribeIndexSummaryAsync(indices) | Index summary | |
| Market subscribe | SubscribeMarketAsync(symbols) | Master data (ceiling, floor, reference) |
SubscribeMarketFlagAsync() | Trading-session flag | |
| Trading subscribe | SubscribeOrderStatusAsync(accountNo = "*") | Order status (includes FCO events) |
SubscribeFcoOrderStatusAsync(accountNo = "*") | Alias for SubscribeOrderStatusAsync | |
SubscribePortfolioAsync(accountNo = "*") | Portfolio changes | |
UnsubscribePortfolioAsync(accountNo = "*") / UnsubscribeOrderStatusAsync(accountNo = "*") | Unsubscribe trading events | |
| Management | ListSubscriptionAsync(timeoutMs = 5000) | Subscriptions the server holds (Dictionary<string, List<string>>) |
UnsubscribeAllAsync() | Remove every subscription, returns what was removed | |
| Unsubscribe | UnsubscribeSymbolAsync(symbols) | Unsubscribe all channels for a symbol |
UnsubscribeSymbolTradeAsync(symbols) | Unsubscribe trade | |
UnsubscribeSymbolQuoteAsync(symbols) | Unsubscribe quote | |
UnsubscribeSymbolRoomAsync(symbols) | Unsubscribe foreign room | |
UnsubscribeSymbolPutThroughAsync(symbols) | Unsubscribe put-through | |
UnsubscribeSymbolOddLotAsync(symbols) | Unsubscribe odd lot | |
UnsubscribeSymbolOhlcvAsync(symbols, interval) | Unsubscribe OHLCV | |
UnsubscribeBoardAsync(boards) | Unsubscribe board | |
UnsubscribeIndexAsync(indices) | Unsubscribe index | |
UnsubscribeIndexTradeAsync(indices, interval?) | Unsubscribe index ticks / candles (same interval as at subscribe time) | |
UnsubscribeIndexSummaryAsync(indices) | Unsubscribe index summary | |
UnsubscribeMarketAsync(symbols) / UnsubscribeMarketFlagAsync() | Unsubscribe master data / session flag |
Every method also takes a CancellationToken ct; the Subscribe* methods also take Action<JsonElement>? onResponse to receive the server's acknowledgement frame. The SDK also has many aliases (for example SubscribeOrdersAsync, SubscribeTradesAsync, SubscribeQuotesAsync, SubscribeIndexTicksAsync, SubscribeIndexConstituentsAsync) that point to the methods above.
6.7. Real-time Index Data
StreamingService streams market index data (VNINDEX, VN30, HNXINDEX, ...) over the market-data WebSocket. There are three kinds of index data, one topic each:
| You want | Topic | Method | Message type |
|---|---|---|---|
| Ticks of the index itself (value, change, advancers/decliners, totals) | trade.index.CODE | SubscribeIndexTradeAsync | IndexTickMessage |
| Index candles (open, high, low, close, volume) every 1 or 5 minutes | trade.index.CODE@1m or @5m | SubscribeIndexTradeAsync with interval | IntervalMessage |
| Index summary (a snapshot of the index statistics) | indexsummary.CODE | SubscribeIndexSummaryAsync | IndexSummaryMessage |
| Trade, quote and foreign room of the symbols inside an index basket | trade, quote, room with .CODE@* | SubscribeIndexAsync | TradeMessage, QuoteMessage, ForeignRoomMessage |
Use these for price boards, index charts or dashboards. For the index list and historical index summary over REST see sections 3.2 and 3.3.
Prerequisites: AuthClient is authenticated; a StreamClient has been ConnectAsync-ed (the methods live on stream.Streaming); the API Key may stream market data (the API reference lists scope stream:DATA:indexsummary for the index summary). Index streaming is read-only and needs no PrivateKey.
| Method | Parameters | Description |
|---|---|---|
SubscribeIndexTradeAsync | string[] indices, string? interval = null | Index ticks (trade.index.CODE). With interval of "1m" or "5m" it gives index candles. |
UnsubscribeIndexTradeAsync | string[] indices, string? interval = null | Unsubscribe. Pass the same interval used when subscribing. |
SubscribeIndexSummaryAsync | string[] indices | Index summary (indexsummary.CODE). No interval. |
UnsubscribeIndexSummaryAsync | string[] indices | Unsubscribe |
SubscribeIndexAsync / UnsubscribeIndexAsync | string[] indices | Trade, quote, room of the symbols in the basket, sent as trade.CODE@*, quote.CODE@*, room.CODE@* |
Note (unconfirmed): the
@*fan-out semantics ofSubscribeIndexAsync/SubscribeBoardAsynchave not been confirmed against a live server. The SDK only builds the topic and sends it. In addition, the exact meaning of each tick field (the unit ofValue,TotalMatchQuantity..., and whether they are cumulative over the session) has not been confirmed with the server; the model table in Utilities is only a mapping from server keys to properties. UseRaw/Datawhen you need to cross-check.
Topic rules. The SDK validates the topic before sending (Topic.Build); a violation throws ValidationException with no network call.
- One topic per index. A
string[] indicesbecomes one topic per element; a comma-separated list inside one string is rejected, and so is an empty array. - Codes are letters and digits. The SDK upper-cases them, and the alias
UPCOMINDEXis rewritten toHNXUPCOMINDEX. intervalis valid only fortrade.index, and must be"tick","1m"or"5m". With an interval the index must be a specific code (not a wildcard).indexsummarydoes not accept an interval. Per the API reference it takes only index codes, not board or security codes.- The
@*fan-out ofSubscribeIndexAsyncneeds a specific index code. It is not valid withtrade.indexorindexsummary. SubscribeIndexAsyncproduces a lot of messages (every symbol in the basket): use it deliberately, especially in long-running processes.
Handlers. Set handlers before ConnectAsync/subscribing. Use OnData (or SetOnData) for every parsed data message (IndexTickMessage, IntervalMessage, IndexSummaryMessage, TradeMessage, ...) and pattern-match on the type; OnIndexSummary receives only index summaries; OnConnection tracks the connection. There is no type-specific handler for index ticks or candles.
For the structure of IndexTickMessage, IntervalMessage, IndexSummaryMessage see Streaming Models. The parser returns an IntervalMessage when the topic ends with @1m or @5m, otherwise an IndexTickMessage. Numeric tick fields are null when the server does not send them.
The API reference lists the index summary keys: s, b, ss, t, v, c, cr, adv, dec, nc, ce, fl, tmv, tmq, tvp, tqp, tq, tv; tq equals tmq plus tqp, and tv equals tmv plus tvp. IndexSummaryMessage does not map to typed properties: read it with Get(params string[] keys) (returns JsonElement?) or Data.
Example 1: stream VNINDEX ticks and 1-minute candles
using SsiSdk;
using SsiSdk.Models;
stream.Streaming.OnConnection = e =>
Console.WriteLine($"[CONN] {e.State} (reconnect #{e.ReconnectCount})" +
(e.Error is null ? "" : $" error: {e.Error.GetType().Name}"));
stream.Streaming.OnData = msg =>
{
switch (msg)
{
case IntervalMessage bar:
Console.WriteLine($"[BAR] {bar.Symbol} {bar.IntervalTime} O:{bar.Open} H:{bar.High} L:{bar.Low} C:{bar.Close} V:{bar.Volume}");
break;
case IndexTickMessage idx:
Console.WriteLine($"[INDEX] {idx.Symbol} {idx.Value} ({idx.ChangePercent}%) adv/dec: {idx.Advances}/{idx.Declines}");
break;
}
};
await stream.ConnectAsync();
await stream.Streaming.SubscribeIndexTradeAsync(["VNINDEX", "VN30"]); // ticks
await stream.Streaming.SubscribeIndexTradeAsync(["VNINDEX"], "1m"); // 1-minute candles
await stream.WaitAsync(60_000); // listen for 60 seconds
await stream.Streaming.UnsubscribeIndexTradeAsync(["VNINDEX", "VN30"]);
await stream.Streaming.UnsubscribeIndexTradeAsync(["VNINDEX"], "1m"); // same interval as at subscribe time
await stream.DisconnectAsync();Example 2: index summary with a typed handler
stream.Streaming.OnIndexSummary = m =>
Console.WriteLine($"[SUMMARY] {m.Index} value={m.Get("v")} change%={m.Get("cr")}");
await stream.ConnectAsync();
await stream.Streaming.SubscribeIndexSummaryAsync(["VNINDEX", "HNXINDEX"]);
await stream.WaitAsync(60_000);Example 3: list and clean up subscriptions
var subs = await stream.Streaming.ListSubscriptionAsync(timeoutMs: 5000);
foreach (var (channel, topics) in subs)
Console.WriteLine($"{channel}: {topics.Count} topics");
var removed = await stream.Streaming.UnsubscribeAllAsync();
Console.WriteLine($"Removed {removed.Values.Sum(v => v.Count)} topics");The trading channel of ListSubscriptionAsync may contain topics that include account numbers: do not print them verbatim.
Index streaming error handling
| Situation | SDK throws | Notes |
|---|---|---|
| Bad index code, comma-separated list, empty array, bad interval | ValidationException | Nothing sent |
Subscribe before ConnectAsync | WebSocketException ("Not connected. Call ConnectAsync() first.") | Call ConnectAsync first |
| Connection fails after the retries | WebSocketException | Thrown by ConnectAsync |
| Connection lost | No exception to the caller | The SDK reconnects and re-sends subscriptions; watch OnConnection. If it gives up, State is failed and LastError is set. |
| Exception inside your callback | The SDK swallows and logs it | Handle errors inside the callback yourself |
This document does not describe how the market-data server reports a missing scope. Handle SsiException as the common base class (see Error Handling) and check the API Key's scopes.
More guides: Tutorial 16: Advanced WebSocket and Market Data WebSocket.
7. Trading WebSocket
The TradingWs* classes (TradingWs, TradingWsOptions, TradingWsClient, TradingWsService, TradingWsResponse) live in the root SsiSdk namespace, next to AuthClient, Config and TradingWsError.
Migrating from 3.3.1 to 3.3.2 (breaking): as of 3.3.2 the Trading WebSocket is no longer experimental and these classes moved from
SsiSdk.ExperimentaltoSsiSdk. The old namespace was removed with no alias or[Obsolete]forwarder, sousing SsiSdk.Experimental;no longer compiles. Replaceusing SsiSdk.Experimental;withusing SsiSdk;. Names, signatures and defaults are unchanged. The log source changed fromssi_sdk.experimental.trading_wstossi_sdk.trading_ws.
The Trading WebSocket is a separate socket (default wss://api.ssi.com.vn/ws/v3/trading) for trading commands. You send a command frame (order.place, query.position, ...) and receive an acknowledgement frame, and you can also subscribe to the order and portfolio events of one account on the same socket. Use it when you want to send orders and queries over a persistent connection instead of one REST request each. It is not a market-data socket: for prices and market data use StreamClient (comparison).
7.1. Prerequisites and connection
- An API Key allowed to use the trading WebSocket (scope
tradingws:*:*per the API reference; at most 10 connections per API Key). ConfighasApiKey,ApiSecretandPrivateKey.PrivateKeyis required for every state-changing command (7.3).AuthClientis authenticated.TradingWs.ConnectAsynctakes the access token fromauth.EnsureAuthenticatedAsync, so authenticate first (OTP / Smart OTP, see Client Classes).- The URL is derived from
Config.TradingWsDomain(defaultwss://api.ssi.com.vn/ws/v3/trading);Config.TradingWsUrlis read-only. The SDK addswss://if the scheme is missing and/ws/v3/tradingif the path is missing. The final URL must bewss://, otherwise the client constructor throwsWebSocketException.
using SsiSdk;
using SsiSdk.Models;
await using var ws = await TradingWs.ConnectAsync(auth);
Console.WriteLine($"Connected: {ws.Client.IsConnected}");| Component | Parameters | Returns | Description |
|---|---|---|---|
TradingWs.ConnectAsync | AuthClient auth, TradingWsOptions? options = null, ct | Task<TradingWsService> | Opens the socket with the current token and returns the service. If the connection fails, it closes the client and rethrows the exception. |
TradingWsService.CloseAsync / DisposeAsync | none | Task / ValueTask | Closes the socket |
TradingWsService.Client | none | TradingWsClient | Low-level client: OnEvent, RateLimit, IsConnected, RequestAsync, SubscribeAsync, UnsubscribeAsync, PingAsync |
The server may accept the socket and then reject it right away (bad token, missing scope, rate limit). The SDK waits HandshakeGraceMs for this rejection frame and then throws TradingWsError. A rejection frame has no id and carries a code of 401, 403, 429 or 500.
TradingWsOptions
| Option | Type | Default | Description |
|---|---|---|---|
RequestTimeoutMs | int? | null | How long to wait for a command's response. Through TradingWs.ConnectAsync, null becomes Config.TimeoutMs (default 60000, or 10000 if that value is less than or equal to 0). A TradingWsClient created directly uses 10000. |
SignatureEncoding | string | "auto" | "auto", "base64" or "hex". Any other value throws ValidationException. |
HandshakeGraceMs | int | 500 | How long to wait for a rejection frame after connecting |
RespectRateLimit | bool | true | Wait when the server reports the quota is exhausted |
MaxRateLimitWaitMs | int | 2000 | Maximum wait for the rate limit |
PingIntervalMs | int? | 30000 | Ping period. null or 0 turns the ping loop off. |
UserAgent | string? | null | null uses Config.UserAgent |
All durations are integer milliseconds. The XML comment of RequestTimeoutMs in the SDK still says "10000 when unset"; the table above reflects the code.
7.2. Operations
All operations live on TradingWsService. Each one sends a command and returns after the acknowledgement frame arrives. Commands are never resent (see "Reconnect, timeout and rate limit" in 7.3).
Regular orders
| Method | Command | Parameters | Returns | Description |
|---|---|---|---|---|
PlaceOrderAsync | order.place | accountNo, symbol, side, int quantity, decimal? price, orderType, clientRequestId?, ct | PlaceOrderResponse | Place a new order. Validation matches the REST PlaceOrderAsync. |
AmendOrderAsync | order.amend | accountNo, decimal? price, int? quantity, orderId?, clientRequestId?, ct | ModifyOrderResponse | Change the price or the quantity (not both) |
CancelOrderAsync | order.cancel | accountNo, orderId?, clientRequestId?, ct | CancelOrderResponse | Cancel an order by orderId or clientRequestId |
BatchNewOrdersAsync | order.batchNew | IReadOnlyCollection<BatchPlaceOrderItem> orders, ct | TradingWsResponse | Place a batch (1 to Config.MaxBatchOrders) |
BatchCancelOrdersAsync | order.batchCancel | IReadOnlyCollection<BatchCancelOrderItem> orders, ct | TradingWsResponse | Cancel a batch |
Note: per the API reference, the acknowledgement frame for an order over WebSocket carries only the status
PD(pending) for each order and has noorderId; the real result arrives later as an event onorder.<accountNo>. Subscribe to order events if you need the final status. The batch item models are the same as REST, see 5.6 and Batch Order Models.
Conditional orders (FCO)
| Method | Command | Description |
|---|---|---|
PlaceFcoGtdAsync, PlaceFcoStopAsync, PlaceFcoStopLimitAsync, PlaceFcoTrailingStopAsync, PlaceFcoTrailingStopLimitAsync, PlaceFcoOcoAsync, PlaceFcoBullBearAsync | fco.place | Place a conditional order (FCOPlaceResponse). Parameters match the REST FCO methods (5.5), plus an optional code. |
CancelFcoAsync(fcoId, code?, ct) | fco.cancel | Cancel a conditional order (FCOCancelResponse) |
ListFcoAsync(accountNo, fcoId?, type?, processStatus?, symbol?, side?, fromDate?, toDate?, pageIndex?, pageSize?, ct) | fco.list | List conditional orders (TradingWsResponse). Accepts only one processStatus. |
FcoOrderBookAsync(fcoId, pageIndex?, pageSize?, ct) | fco.orderbook | The orders generated from a conditional order |
FcoStatusHistoryAsync(fcoId, ct) | fco.statusHistory | Status history of a conditional order |
Queries (read-only) — all return TradingWsResponse.
| Method | Command | Parameters | Description |
|---|---|---|---|
QueryPositionAsync | query.position | accountNo, bool querySummary = true | Positions |
QueryMaxBuySellAsync | query.maxBuySell | accountNo, symbol, decimal? price = null | Maximum buy/sell quantity |
QueryAccountBalanceAsync | query.accountBalance | accountNo | Account balance |
QueryPpmmrAccountAsync | query.ppmmrAccount | accountNo | PPMMR info |
QueryOrderBookAsync | query.orderBook | accountNo, fromDate?, toDate?, symbol?, orderStatus?, pageIndex?, pageSize? | Order book |
Event subscriptions. Events are sent on channel TRADING with topics order.<accountNo> and portfolio.<accountNo>.
| Method | Parameters | Description |
|---|---|---|
SubscribeOrderEventsAsync / UnsubscribeOrderEventsAsync | accountNo, ct | Subscribe / unsubscribe the order events of one account |
SubscribePortfolioEventsAsync / UnsubscribePortfolioEventsAsync | accountNo, ct | Subscribe / unsubscribe the portfolio events of one account |
UnsubscribeAllEventsAsync | ct | Remove every trading subscription of this socket |
Subscribe to one account at a time. The wildcard * or a comma-separated list throws ValidationException. Events arrive at ws.Client.OnEvent, an Action<JsonElement> that receives raw JSON (this socket has no typed handlers).
Generic command: CommandAsync(string method, object? parameters, ct) sends any command and returns the first response frame (TradingWsResponse), without retry. For the structure of TradingWsResponse see Utilities.
7.3. Signing
State-changing commands are signed: order.place, order.amend, order.cancel, order.batchNew, order.batchCancel, fco.place and fco.cancel (case-insensitive). Queries and subscriptions are not signed.
- A frame looks like
{"id", "method", "params", "signature", "timestamp"}. The signature is computed over the exact compact string ofparams, Base64-encoded by default. - A state-changing command without a
PrivateKeythrowsWebSocketException. - In
"auto"mode, if the server rejects a state-changing command because of a signature error (401006,401008or401013), the SDK retries once with the other encoding. This is safe because a rejected signature means the command was not executed. If both encodings are rejected, the SDK throwsTradingWsErrorwith a hint (BothRefused). SetSignatureEncodingto"base64"or"hex"to avoid the extra attempt. - The signature and the signed params never appear in exception messages.
Reconnect, timeout and rate limit
- No automatic reconnect. If the connection drops, create a new
TradingWsServicewithTradingWs.ConnectAsyncand subscribe again yourself. Pending commands fail withWebSocketException. - No command retry (apart from the signature-encoding retry above). A timeout throws
WebSocketException("timed out waiting for a response (not retried)"). If the socket closes first, the message is "connection closed before the response arrived". Sending while not connected throws "not connected". After a timeout or a closed socket, the outcome of a state-changing command is unknown: check the order book or events before resending. - Ping. The SDK pings every
PingIntervalMs. The ping loop stops when it hits aWebSocketException. - Rate limit. When
RespectRateLimitis on and the latest response reportedremaining == 0, the next command waitsmin(resetMs, MaxRateLimitWaitMs)before sending.ws.Client.RateLimitholds the latest quota. A rejection frame with code 429 is reported asTradingWsError.
7.4. Differences from StreamClient
StreamClient (Streaming) | Trading WebSocket (TradingWs) | |
|---|---|---|
| Purpose | Market data and trading events, push style | Trading commands, queries and order events, command/acknowledge style |
| URL | Config.StreamingUrl | Config.TradingWsUrl (derived from TradingWsDomain) |
| Reconnect | Automatic, re-subscribes the recorded topics | None, you reconnect and re-subscribe |
| Events | Typed handlers (OnData, OnOrder, ...) and OnConnection | Raw JsonElement at Client.OnEvent |
| Subscription | Many topics, comma lists rejected, topic rules in Topic | One account at a time, no wildcard |
Both live in the SsiSdk namespace. The two sockets are independent: you can use them at the same time.
7.5. Examples
Controls applied to the Trading WebSocket examples: keys and OTP come from environment variables or manual input, the account is a placeholder, the signature is produced by the SDK from
PrivateKey(it never reaches logs or exceptions),clientRequestIdis at most 20 characters and validated by the SDK before sending, the examples print the shape of results instead of the content (to avoid leaking account numbers), and commands are not retried automatically.
Example 1: read-only queries and order events. Read-only; it prints the method name and field names, not the content, because results may contain account numbers.
var account = "YOUR_ACCOUNT_NO";
static string Shape(TradingWsResponse r)
{
var keys = r.Result.ValueKind == System.Text.Json.JsonValueKind.Object
? string.Join(",", r.Result.EnumerateObject().Select(p => p.Name).Take(8))
: r.Result.ValueKind.ToString();
return $"status={r.Status} method={r.Method} result=[{keys}]";
}
try
{
await using var ws = await TradingWs.ConnectAsync(auth, new TradingWsOptions
{
RequestTimeoutMs = 10_000,
PingIntervalMs = 30_000,
});
var events = 0;
ws.Client.OnEvent = _ => Interlocked.Increment(ref events);
Console.WriteLine(Shape(await ws.QueryAccountBalanceAsync(account)));
Console.WriteLine(Shape(await ws.QueryPositionAsync(account)));
Console.WriteLine(Shape(await ws.QueryMaxBuySellAsync(account, "SSI")));
await ws.SubscribeOrderEventsAsync(account);
await Task.Delay(TimeSpan.FromSeconds(30));
await ws.UnsubscribeOrderEventsAsync(account);
Console.WriteLine($"Events received: {events}");
}
catch (TradingWsError ex)
{
// The server rejected the socket or a command
Console.WriteLine($"Trading WS rejected: status={ex.Status} code={ex.ErrorCode} {ex.ServerMessage}");
}
catch (SsiException ex)
{
Console.WriteLine($"Error: {ex.GetType().Name} [{ex.Code}] {ex.Message}");
}Example 2: place then cancel one order.
Warning: this example sends a real order. Use a test account and a price far from the market so the order rests and does not match.
var account = "YOUR_ACCOUNT_NO";
var clientRequestId = $"ws-{Guid.NewGuid().ToString("N")[..8]}"; // max 20 characters
try
{
await using var ws = await TradingWs.ConnectAsync(auth);
// Follow the real result on the order event stream
ws.Client.OnEvent = _ => Console.WriteLine("order event received");
await ws.SubscribeOrderEventsAsync(account);
var placed = await ws.PlaceOrderAsync(
account, "SSI", OrderSide.Buy, 100, 20000m, OrderType.LO, clientRequestId);
Console.WriteLine("order.place acknowledged");
// The acknowledgement may carry no orderId: cancel by clientRequestId
var cancelled = await ws.CancelOrderAsync(account, clientRequestId: clientRequestId);
Console.WriteLine("order.cancel acknowledged");
}
catch (TradingWsError ex)
{
Console.WriteLine($"Rejected: status={ex.Status} code={ex.ErrorCode} {ex.ServerMessage}");
}
catch (ValidationException ex)
{
Console.WriteLine($"Invalid input (nothing sent): {ex.Message}");
}
catch (WebSocketException ex)
{
// Timeout, closed socket or missing PrivateKey. After a timeout the order may or may not have been created.
Console.WriteLine($"WebSocket error: {ex.Message}");
}TradingWsError derives from WebSocketException, so catch it first.
Example 3: batch over WebSocket.
var runId = Guid.NewGuid().ToString("N")[..8];
var items = new List<BatchPlaceOrderItem>
{
new("YOUR_ACCOUNT_NO", "SSI", OrderSide.Buy, 100, 20000m, OrderType.LO, ClientRequestId: $"w-{runId}-1"),
new("YOUR_ACCOUNT_NO", "SSI", OrderSide.Buy, 100, 19950m, OrderType.LO, ClientRequestId: $"w-{runId}-2"),
};
var response = await ws.BatchNewOrdersAsync(items); // ws: the TradingWsService from ConnectAsync
// The result is raw JSON: print only the shape, then read the final status from order events
var kind = response.Result.ValueKind;
Console.WriteLine($"status={response.Status} method={response.Method} result={kind}");7.6. Error handling
| Situation | Exception | Notes |
|---|---|---|
Invalid input (empty accountNo, wildcard or list when subscribing, bad batch) | ValidationException | Nothing sent |
Invalid SignatureEncoding | ValidationException | Thrown by the TradingWsClient constructor |
Trading WS URL is not wss:// | WebSocketException | Thrown by the TradingWsClient constructor |
| Server rejects the socket or a command | TradingWsError | Status, ErrorCode, ServerMessage. Never contains signed params. |
| Signature rejected in both encodings | TradingWsError | With a hint; check PrivateKey and the key format |
State-changing command without PrivateKey | WebSocketException | Nothing sent |
| Timeout, closed socket, not connected | WebSocketException | No retry. The outcome of a state-changing command is unknown. |
| Token error before connecting | AuthenticationException | From auth.EnsureAuthenticatedAsync |
Typical ErrorCode values are the signature codes 401006 (missing signature), 401008 (empty signature body) and 401013 (signature mismatch). See Error Codes for the full list and Error Handling for the base exceptions.
Step-by-step guide: Tutorial 17: Trading WebSocket.