---
title: "Account State Management for Crypto APIs: Node.js Guide"
description: "Master account state management crypto API patterns in Node.js. Learn to reconcile REST snapshots with awaitable WebSocket streams for reliable balance data."
canonical: "https://siebly.io/blog/account-state-management-for-crypto-apis-nodejs-guide"
---

# 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.

## Technical Disclaimer

These articles are software engineering references for exchange API integrations. They are not financial, investment, legal, tax, compliance, or trading advice. Use public data, demo, testnet, or paper workflows first. Keep API credentials out of frontend code and disable withdrawal permissions for automation keys.

## Overview {#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:

- [binance](/sdk/binance/javascript)
- [bybit-api](/sdk/bybit/javascript)
- [okx-api](/sdk/okx/javascript)
- [bitget-api](/sdk/bitget/javascript)
- [coinbase-api](/sdk/coinbase/javascript)
- `bitmart-api`
- [gateio-api](/sdk/gate/javascript)
- [kucoin-api](/sdk/kucoin/javascript)
- [@siebly/kraken-api](/sdk/kraken/javascript)
- [@siebly/htx-api](/sdk/htx/javascript)

## Key Takeaways {#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](/sdk/binance/javascript), [bybit-api](/sdk/bybit/javascript), [okx-api](/sdk/okx/javascript), [bitget-api](/sdk/bitget/javascript) (V3 / UTA keys), [gateio-api](/sdk/gate/javascript), [kucoin-api](/sdk/kucoin/javascript), [@siebly/kraken-api](/sdk/kraken/javascript), and [@siebly/htx-api](/sdk/htx/javascript). [coinbase-api](/sdk/coinbase/javascript) 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 {#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 {#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.

```ts title="Imported example"
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()`:

```ts title="Imported example"
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 {#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](/sdk/bybit/javascript):

`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 {#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 {#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:

```ts title="Imported example"
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 {#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](/sdk/bybit/javascript) 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 {#signing-because-it-is-not-one-hmac}

- [binance](/sdk/binance/javascript): `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](/sdk/okx/javascript): `apiKey`, `apiSecret`, `apiPass`. HMAC-SHA256. The passphrase is required.
- [bitget-api](/sdk/bitget/javascript): `apiKey`, `apiSecret`, `apiPass`. HMAC-SHA256 or RSA. Passphrase required. V3 WebSocket API needs UTA keys.
- [kucoin-api](/sdk/kucoin/javascript): `apiKey`, `apiSecret`, `apiPassphrase`. HMAC-SHA256. Passphrase is not the account password.
- `bitmart-api`: `apiKey`, `apiSecret`, `apiMemo`. HMAC-SHA256. Memo is required.
- [@siebly/htx-api](/sdk/htx/javascript): `apiKey`, `apiSecret`. HMAC-SHA256.
- [gateio-api](/sdk/gate/javascript): `apiKey`, `apiSecret`. HMAC-SHA512.
- [@siebly/kraken-api](/sdk/kraken/javascript): `apiKey`, `apiSecret`. SHA-256 of the payload, then HMAC-SHA512. The secret is the base64 private key from Kraken.
- [coinbase-api](/sdk/coinbase/javascript): `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".

```ts title="Imported example"
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 {#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 {#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:

```ts title="Imported example"
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):

```ts title="Imported example"
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:

```ts title="Imported example"
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:

```ts title="Imported example"
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:

```ts title="Imported example"
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:

```ts title="Imported example"
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:

```ts title="Imported example"
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 {#rest-reads-you-can-use-as-the-snapshot}

Bybit unified wallet:

```ts title="Imported example"
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):

```ts title="Imported example"
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.

```ts title="Imported example"
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:

```ts title="Imported example"
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 {#per-venue-notes}

Constructor field names are not consistent. [binance](/sdk/binance/javascript) uses `api_key` and `api_secret`. [bybit-api](/sdk/bybit/javascript) 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](/sdk/binance/javascript/tutorial).
- OKX ([okx-api](/sdk/okx/javascript)): `account`, `positions`, `orders`. Passphrase required. `market` is `GLOBAL` by default, `EEA` or `US` if that is your site.
- Bitget ([bitget-api](/sdk/bitget/javascript)): 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](/sdk/kraken/javascript)): 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](/sdk/gate/javascript)): `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](/sdk/kucoin/javascript)): spot private v1 topics such as `/account/balance` and `/spotMarket/tradeOrders` on `spotPrivateV1`. Passphrase required.
- HTX ([@siebly/htx-api](/sdk/htx/javascript)): `accounts.update` and `orders#*` on `WS_KEY_MAP.spotPrivateV2`. The client signs the auth request before subscribe.
- Coinbase ([coinbase-api](/sdk/coinbase/javascript)): 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 {#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 {#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 {#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](/sdk) 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 {#frequently-asked-questions}

### How do I handle WebSocket reconnection without losing account state? {#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? {#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? {#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? {#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? {#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? {#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](/sdk/bybit/javascript) and [okx-api](/sdk/okx/javascript) will not do that mapping for you.

### Can I use these SDKs for paper trading or testnet? {#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? {#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

- [Mastering Crypto Order State Management in Node.js and TypeScript](/blog/mastering-crypto-order-state-management-in-nodejs-and-typescript)
- [Crypto Trading System Reliability: WebSockets, Reconnects, and Account State](/blog/crypto-websocket-reliability-account-state)
- [Real-Time Crypto Market Data API: Node.js Guide 2026](/blog/real-time-crypto-market-data-api-nodejs-guide-2026)


## Related Siebly Resources

- [Binance JavaScript SDK](/sdk/binance/javascript)
- [Bybit JavaScript SDK](/sdk/bybit/javascript)
- [OKX JavaScript SDK](/sdk/okx/javascript)
- [Siebly SDK directory](/sdk)
- [Exchange State Management](/ai/exchange-state)
