Blog
WebSocketsTrading systemsTypeScriptNode.jsJavaScript

Account State Management for Crypto APIs: Node.js Guide

Master account state management crypto API patterns in Node.js. Learn to reconcile REST snapshots with awaitable WebSocket streams for reliable balance data.

Siebly.io10 min readMarkdown

Overview

A trading process that decides from a stale balance will send orders the exchange rejects, or worse, orders it should never have sent. The usual way to keep a local copy of the account is a REST snapshot, then a private WebSocket for everything that changes after that. The awkward part is the gap between those two channels. A fill can arrive on the socket before the REST place-order response, and the JSON for "balance" is different on every venue.

The Siebly JavaScript SDKs cover the transport for that work: request signing, timestamps, private authentication, heartbeats, and reconnects. They are TypeScript clients for Node.js. They do not merge those events into your own account model, and they do not throttle requests for you. Reconciliation and rate limits stay in your code.

npm package names used below:

Key Takeaways

  • Seed local state from a private REST call, then apply private WebSocket events. On reconnect, take a fresh REST snapshot. The SDK reopens the socket and resubscribes. It does not replay events you missed.
  • Signing is not one algorithm. HMAC-SHA256 covers Binance, Bybit, OKX, Bitget, KuCoin, BitMart, and HTX, with extra key material on some of them. Gate.io and Kraken sign with HMAC-SHA512. Coinbase uses a JWT (ECDSA or Ed25519). Binance and Bybit also accept RSA. Binance also accepts Ed25519, which is what you want for its WebSocket API login.
  • Await WebsocketAPIClient methods when the venue has a WebSocket API. That exists on binance, bybit-api, okx-api, bitget-api (V3 / UTA keys), gateio-api, kucoin-api, @siebly/kraken-api, and @siebly/htx-api. coinbase-api and bitmart-api are subscribe-and-listen clients. They do not have that promise-style order client.
  • Order status strings are venue-specific. Bybit really does use both Created and New. Binance uses NEW, PARTIALLY_FILLED, FILLED, and others. OKX uses live, partially_filled, filled, canceled. Map them in your own types.
  • Demo and testnet flags exist only where the exchange has an environment and the SDK exposes it. KuCoin and HTX clients in these packages do not.

What you are actually syncing

Account state here means the copy of the account your process keeps in memory: balances, open orders, and positions if the product has them. Calling REST before every decision is too slow for anything that reacts to fills. A socket is faster, and it is also incomplete on its own. You need a baseline, then deltas.

The baseline is a private REST read: wallet balance, open orders, positions. After that, subscribe to the private user stream. If you subscribe first and the snapshot returns later, drop socket events that are older than the snapshot, or you will apply a fill and then overwrite it with a balance from before the fill.

These clients give you authenticated REST and an authenticated socket. They do not decide which event wins. That comparison is yours.

REST snapshot and WebSocket updates

Binance spot is the example people still get wrong. The old user-data listen key for spot is deprecated. The SDK opens spot user data through the WebSocket API, and it will resubscribe after a reconnect unless you turn that off. Futures still use a listen key, and the SDK creates it and keeps it alive. You do not PUT a listen key on a timer yourself.

Imported example

TypeScript
import { WebsocketAPIClient, WS_KEY_MAP } from "binance";

const wsClient = new WebsocketAPIClient({
  api_key: process.env.API_KEY_COM,
  api_secret: process.env.API_SECRET_COM,
  beautify: true,
  // resubscribeUserDataStreamAfterReconnect defaults to true
});

const response = await wsClient.subscribeUserDataStream(WS_KEY_MAP.mainWSAPI);

USD-M futures user data is still the listen-key connection, handled inside subscribeUsdFuturesUserDataStream():

Imported example

TypeScript
import { WebsocketClient } from "binance";

const wsClient = new WebsocketClient({
  api_key: process.env.API_KEY_COM,
  api_secret: process.env.API_SECRET_COM,
  beautify: true,
});

wsClient.subscribeUsdFuturesUserDataStream();

The reconnected handler in the Binance user-data example is the right place to re-read balances, positions, and open orders. The socket coming back does not mean you saw every event from the gap.

Typed models

Do not feed raw responses into one shared interface. A Bybit wallet balance and a Kraken balance are different objects, different field names, different units.

Bybit V5, from OrderStatusV5 in bybit-api:

Created, New, Rejected, PartiallyFilled, PartiallyFilledCanceled, Filled, Cancelled, Untriggered, Triggered, Deactivated, Active

Binance spot OrderStatus:

NEW, PARTIALLY_FILLED, FILLED, CANCELED, PENDING_CANCEL, REJECTED, EXPIRED

OKX order state on the private order payloads includes live, partially_filled, filled, and canceled. There is no shared Created then New pipeline. Created and New are Bybit values, and Bybit also has states that never pass through both.

Keep the exchange payload, and map the few fields your engine needs (orderId, status, filled qty, free balance) into your own type. The SDK types already describe the raw payload. They do not normalize it across venues.

What the private stream contains

Three things matter for a local account: balances (free vs locked), positions and margin where the product has them, and order updates.

An open limit order locks balance on the venue. BitMart spot events for that are spot/user/order:BTC_USDT and spot/user/balance:BALANCE_UPDATE. Gate.io spot uses spot.balances and spot.orders. If your local free balance ignores that lock, the next order fails for insufficient funds.

On derivatives, unrealized PnL changes equity and liquidation price. OKX sends that on the account and positions channels. Bybit sends it on wallet and position. Store those numbers as strings, or use a decimal library. Number will drift.

Order updates

Treat the private order or execution event as the update, and use REST when you need to repair the book. The SDK emits the exchange message. Your reducer applies it.

A Bybit private subscription from the V5 example:

Imported example

TypeScript
import { WebsocketClient } from "bybit-api";

const wsClient = new WebsocketClient({
  key: process.env.API_KEY_COM,
  secret: process.env.API_SECRET_COM,
});

// Private V5 topics share one private endpoint. Category is ignored here.
wsClient.subscribeV5(["order", "wallet", "position", "execution"], "linear");

wsClient.on("update", (data) => {
  // apply to your local orders / wallet / positions
});

wsClient.on("reconnected", () => {
  // REST snapshot again. Events from the gap are gone.
});

The client pings, reconnects, and resubscribes. You still reconcile.

What the SDK does, and what it leaves to you

Hand-rolled clients spend their life on signatures, clock skew, and silent socket death. These packages sign the request and keep the socket up. bybit-api speaks the current V5 REST and WebSocket API. The old V3 examples in that repo are marked deprecated (use V5).

What you still write:

  • Which REST read is the snapshot, and how you merge it with buffered socket events.
  • Your own request queue. Nothing in these clients sleeps or sheds calls to stay under a weight limit. Binance and Bybit responses can include rate-limit fields. Reading them is your job. A 429 is still your problem.
  • Least-privilege keys. Read-only keys for a monitor. Trade keys for an execution process. Withdrawals off.

Signing, because it is not one HMAC

  • binance: api_key, api_secret. HMAC-SHA256, RSA, or Ed25519. Ed25519 can log in once on the WebSocket API. HMAC and RSA sign each WS API command.
  • bybit-api: key, secret. HMAC-SHA256, or RSA.
  • okx-api: apiKey, apiSecret, apiPass. HMAC-SHA256. The passphrase is required.
  • bitget-api: apiKey, apiSecret, apiPass. HMAC-SHA256 or RSA. Passphrase required. V3 WebSocket API needs UTA keys.
  • kucoin-api: apiKey, apiSecret, apiPassphrase. HMAC-SHA256. Passphrase is not the account password.
  • bitmart-api: apiKey, apiSecret, apiMemo. HMAC-SHA256. Memo is required.
  • @siebly/htx-api: apiKey, apiSecret. HMAC-SHA256.
  • gateio-api: apiKey, apiSecret. HMAC-SHA512.
  • @siebly/kraken-api: apiKey, apiSecret. SHA-256 of the payload, then HMAC-SHA512. The secret is the base64 private key from Kraken.
  • coinbase-api: apiKey, apiSecret (CDP key name and private key). JWT. ECDSA (BEGIN EC PRIVATE KEY) or Ed25519. The client detects which.

OKX private sockets need the passphrase on every account you subscribe with. Miss it and the socket never authenticates, which looks a lot like "no fills".

Imported example

TypeScript
import { WebsocketClient } from "okx-api";

const wsClient = new WebsocketClient({
  accounts: [
{
apiKey: process.env.API_KEY_COM!,
apiSecret: process.env.API_SECRET_COM!,
apiPass: process.env.API_PASSPHRASE_COM!,
},
  ],
});

wsClient.subscribe([
  { channel: "account" },
  { channel: "positions", instType: "ANY" },
  { channel: "orders", instType: "ANY" },
]);

Read keys from the environment. Do not put them in the repo. The examples in each package already do this with process.env.

A snapshot, then the private stream

One workable loop:

  1. Open the private socket and buffer events.
  2. REST-read balances, open orders, and positions.
  3. Drop buffered events that are older than that snapshot.
  4. Apply the rest.
  5. On reconnected, do steps 2-4 again.

Not every venue gives you a single account-wide sequence number. Bybit private messages carry fields you can order with. Binance user events carry an event time. OKX events carry timestamps. Use what that payload actually has. If you cannot prove the event is newer than the snapshot, fetch REST again.

The REST body is the repair tool when the socket and your memory disagree. Apply it, then continue from events that are newer than it.

Await the WebSocket API when the venue has one

WebsocketAPIClient sends a command on the WebSocket API and resolves when the matching response arrives. That response means the exchange accepted or rejected the command. It is not a fill. Fills still arrive on the private stream.

Bybit:

Imported example

TypeScript
import { WebsocketAPIClient } from "bybit-api";

const wsClient = new WebsocketAPIClient({
  key: process.env.API_KEY_COM,
  secret: process.env.API_SECRET_COM,
  // testnet: true,
  // demoTrading does not support the Bybit WS API (events only, as of the Jan 2025 note in the SDK)
});

const response = await wsClient.submitNewOrder({
  category: "linear",
  symbol: "BTCUSDT",
  orderType: "Limit",
  qty: "0.001",
  side: "Buy",
  price: "50000",
});

OKX (passphrase again):

Imported example

TypeScript
import { WebsocketAPIClient } from "okx-api";

const wsClient = new WebsocketAPIClient({
  accounts: [
{
apiKey: process.env.API_KEY_COM || "",
apiSecret: process.env.API_SECRET_COM || "",
apiPass: process.env.API_PASSPHRASE_COM || "",
},
  ],
});

const res = await wsClient.submitNewOrder({
  instId: "BTC-USDT",
  tdMode: "cash",
  side: "buy",
  ordType: "market",
  sz: "100",
});

Kraken spot. The SDK fetches the private WS token for you. Do not pass token yourself:

Imported example

TypeScript
import { WebsocketAPIClient } from "@siebly/kraken-api";

const client = new WebsocketAPIClient({
  apiKey: process.env.API_SPOT_KEY,
  apiSecret: process.env.API_SPOT_SECRET,
});

const addOrderResponse = await client.submitSpotOrder({
  order_type: "limit",
  side: "buy",
  limit_price: 26500.4,
  order_qty: 1.2,
  symbol: "BTC/USD",
});

Gate.io spot:

Imported example

TypeScript
import { WebsocketAPIClient } from "gateio-api";

const client = new WebsocketAPIClient({
  apiKey: process.env.API_KEY,
  apiSecret: process.env.API_SECRET,
});

const newOrder = await client.submitNewSpotOrder({
  text: "t-my-custom-id",
  currency_pair: "BTC_USDT",
  type: "limit",
  account: "spot",
  side: "buy",
  amount: "1",
  price: "10000",
});

Bitget V3 UTA:

Imported example

TypeScript
import { WebsocketAPIClient } from "bitget-api";

const wsClient = new WebsocketAPIClient({
  apiKey: process.env.API_KEY_COM,
  apiSecret: process.env.API_SECRET_COM,
  apiPass: process.env.API_PASS_COM,
});

const res = await wsClient.submitNewOrder("spot", {
  orderType: "limit",
  price: "100",
  qty: "0.1",
  side: "buy",
  symbol: "BTCUSDT",
  timeInForce: "gtc",
});

Coinbase and BitMart stop at subscribe. Coinbase Advanced Trade user data:

Imported example

TypeScript
import { WebsocketClient } from "coinbase-api";

const client = new WebsocketClient({
  apiKey: process.env.API_KEY_NAME,
  apiSecret: process.env.API_PRIVATE_KEY,
});

client.subscribe("user", "advTradeUserData");
client.on("update", (data) => {
  // user channel payloads
});

The Coinbase example subscribes to futures_balance_summary on advTradeUserData the same way. Authentication is the CDP private key, not an HMAC secret.

BitMart spot private:

Imported example

TypeScript
import { WebsocketClient } from "bitmart-api";

const client = new WebsocketClient({
  apiKey: process.env.API_KEY,
  apiSecret: process.env.API_SECRET,
  apiMemo: process.env.API_MEMO,
});

client.subscribe("spot/user/order:BTC_USDT", "spot");
// client.subscribe('spot/user/balance:BALANCE_UPDATE', 'spot');

REST reads you can use as the snapshot

Bybit unified wallet:

Imported example

TypeScript
import { RestClientV5 } from "bybit-api";

const rest = new RestClientV5({
  key: process.env.API_KEY_COM,
  secret: process.env.API_SECRET_COM,
});

const wallet = await rest.getWalletBalance({ accountType: "UNIFIED" });

Binance spot balances (free is what you can still spend):

Imported example

TypeScript
import { MainClient } from "binance";

const client = new MainClient({
  api_key: process.env.API_KEY_COM,
  api_secret: process.env.API_SECRET_COM,
});

const balances = await client.getBalances();
const usdt = balances.find((row) => row.coin === "USDT");

OKX trading balance is getBalance, not getBalances. getBalances is the funding account.

Imported example

TypeScript
import { RestClient } from "okx-api";

const rest = new RestClient({
  apiKey: process.env.API_KEY_COM!,
  apiSecret: process.env.API_SECRET_COM!,
  apiPass: process.env.API_PASSPHRASE_COM!,
});

const balance = await rest.getBalance();
const positions = await rest.getPositions({ instType: "SWAP" });

Kraken spot:

Imported example

TypeScript
import { SpotClient } from "@siebly/kraken-api";

const client = new SpotClient({
  apiKey: process.env.API_SPOT_KEY,
  apiSecret: process.env.API_SPOT_SECRET,
});

const balances = await client.getAccountBalance();
const extended = await client.getExtendedBalance();

getExtendedBalance is the one that includes held amounts. Available is balance plus credit, minus credit used, minus hold_trade.

Per-venue notes

Constructor field names are not consistent. binance uses api_key and api_secret. bybit-api uses key and secret. Most of the others use apiKey and apiSecret. Copy from the example in that package, not from a neighbouring one.

Private topics worth subscribing to:

  • Bybit (bybit-api): order, position, wallet, execution via subscribeV5. One private V5 URL. Heartbeats and resubscribe are inside WebsocketClient.
  • Binance (binance): spot user data via WebsocketAPIClient.subscribeUserDataStream(). Futures via subscribeUsdFuturesUserDataStream() and the coin-m / portfolio equivalents. Listen-key refresh for those futures streams is inside the SDK. Tutorial: Binance JavaScript Tutorial.
  • OKX (okx-api): account, positions, orders. Passphrase required. market is GLOBAL by default, EEA or US if that is your site.
  • Bitget (bitget-api): V3 UTA private topics account, position, order, fill on WebsocketClientV3, with instType: 'UTA' for account events. Classic V2 is a different client.
  • Kraken (@siebly/kraken-api): spot private v2 topics balances and executions on WS_KEY_MAP.spotPrivateV2. The SDK gets the WS token. Staking balances can show up with .B, .F, or .T suffixes.
  • Gate.io (gateio-api): spot.balances, spot.orders, spot.usertrades on spotV4. Signature is SHA-512.
  • BitMart (bitmart-api): spot/user/order:SYMBOL and spot/user/balance:BALANCE_UPDATE. Memo required.
  • KuCoin (kucoin-api): spot private v1 topics such as /account/balance and /spotMarket/tradeOrders on spotPrivateV1. Passphrase required.
  • HTX (@siebly/htx-api): accounts.update and orders#* on WS_KEY_MAP.spotPrivateV2. The client signs the auth request before subscribe.
  • Coinbase (coinbase-api): Advanced Trade user channel on advTradeUserData. Separate clients exist for Exchange, International, Prime, App, and Commerce. Do not assume one class covers all of them.

Demo and testnet

Turn these on only for the client you are testing. They are not a global SDK mode.

  • binance: testnet: true or demoTrading: true. Demo uses live market data with a simulated account. Testnet is a separate market. They are not the same flag.
  • bybit-api: testnet: true. demoTrading: true is for consuming events. The WS API client does not support demo trading.
  • okx-api: demoTrading: true on both RestClient and WebsocketClient.
  • bitget-api: demoTrading: true.
  • gateio-api: useTestnet: true.
  • bitmart-api: demoTrading: true for V2 futures only. Spot stays on production.
  • @siebly/kraken-api: testnet: true on the derivatives client. Spot does not have this. Kraken calls the derivatives environment "demo".
  • coinbase-api: useSandbox: true on the International client. That flag is not an Advanced Trade paper venue.
  • kucoin-api and @siebly/htx-api: no testnet or demo switch in the client.

Rate limits

These SDKs do not queue, delay, or retry for rate limits. That includes REST and the WebSocket API. If you burst getWalletBalance on a timer plus order commands, you will get 429s or the venue's equivalent. Back off yourself. The library will not do it quietly in the background, which is what you want if you care about latency.

Scaling the process

Keep the snapshot-plus-stream loop, and keep the mapping layer next to it. The SDK is there so you are not reimplementing OKX's passphrase sign, Kraken's SHA-512 sign, Binance's listen key, or Bybit's V5 auth. Your code still owns the account object and the rate-limit budget.

Siebly SDKs are the clients behind the samples above. Run them against demo or testnet where that venue has one, with withdrawal permission disabled, before you point the same process at a live key.

Frequently Asked Questions

How do I handle WebSocket reconnection without losing account state?

Listen for reconnected, then REST-read balances, open orders, and positions. The clients reconnect and resubscribe on their own. They do not store the events from the gap, so there is nothing to replay unless you buffered it yourself. The Binance user-data example calls this out in the reconnected handler.

Does the Siebly SDK handle API rate limiting automatically?

No. No client in this set throttles or queues to stay under exchange weight. Some responses include rate-limit numbers (Binance and Bybit). You have to read them and slow down.

What is the best way to synchronize REST snapshots with WebSocket updates?

Subscribe and buffer first, then fetch REST, then drop buffered events older than that snapshot and apply the newer ones. If the venue does not give you a sequence you trust, refetch REST.

How do I securely manage API keys in a Node.js trading application?

Environment variables or a secret manager. A monitor key should be read-only. An execution key should be allowed to trade and not allowed to withdraw.

What is an awaitable WebSocket order?

A WebsocketAPIClient call that returns a promise for the command response. submitNewOrder on Bybit and OKX, submitSpotOrder on Kraken, submitNewSpotOrder on Gate.io, and submitNewOrder on Bitget V3 work this way. The promise resolves on accept or reject. Fills are a later private-stream event. Coinbase and BitMart do not offer this client.

How do I track order state across different crypto exchanges?

Store the raw message, and map status into your own enum. Bybit Filled, Binance FILLED, and OKX filled are the same idea and not the same string. bybit-api and okx-api will not do that mapping for you.

Can I use these SDKs for paper trading or testnet?

Where the exchange and the client support it, yes. See the demo and testnet list above. KuCoin and HTX are not on that list. Bybit demo trading does not support the WebSocket API. BitMart demo is V2 futures only. Kraken testnet is derivatives only.

Why a per-exchange SDK?

Each package is typed to that venue's REST and private WebSocket, including the odd parts: OKX and Bitget passphrases, BitMart memo, Kraken's WS token, Binance Ed25519 login, Gate.io SHA-512. A general library can still place an order. It will not match these private stream topics and WebsocketAPIClient methods one for one.

Related articles

Continue from here

Related Siebly resources

All articles

Subscribe on Substack

Complete the Substack form below to join our newsletter. Substack handles all subscriber data directly.