Scalable Crypto API Integration in Node.js: A Practical Engineering Guide
Build scalable crypto API integration Node.js architectures. Learn to isolate exchange adapters, manage awaitable WebSockets, and use Siebly.io SDKs cleanly.
Overview
A second exchange usually shows where a clean prototype was lying to you. Authentication fields, symbol strings, and WebSocket events do not line up, even when the job is "get me a BTC price." Scalable crypto API integration in Node.js means keeping those differences visible, not papering over them with one shared model.
Siebly.io SDKs sit on the exchange side of that boundary. They sign requests, attach the timestamps the exchange expects, and expose typed request and response shapes for REST and WebSockets. Official exchange documentation is still the source of truth for what an endpoint does.
They do not do two jobs people often assume are included:
- They do not retry REST calls for you. A timeout on a write is your problem to reconcile.
- They do not throttle. None of these clients queue or delay requests to stay under an exchange limit.
WebSocket clients do reconnect, and they resubscribe to topics you already asked for. That is connection recovery, not a retry policy for orders.
This guide uses the current Node.js SDKs for Binance, Bybit, OKX, Bitget, KuCoin, Gate, BitMart, Coinbase, HTX, and Kraken. Package names and method names below match those SDKs.
Key Takeaways
- Keep application workflows, exchange adapters, and operational controls in separate places. The adapter is allowed to look like that exchange.
- Symbol strings, credential fields, and response shapes are part of the contract. Normalizing them too early hides bugs.
- REST clients sign and type requests. They do not retry them, and they do not enforce rate limits.
- WebSocket stream clients and awaitable WebSocket API clients solve different problems. Not every exchange SDK has the second one.
- Start with one public call, against one exchange, and compare it with that exchange's docs before you add another adapter.
Why the boundary has to be explicit
A single-exchange prototype spreads signing, timestamps, response parsing, and socket events through business code. The next API then forces you to find every place that assumed the first exchange's shape. The Siebly SDK overview is the index for the integration layer. The exchange docs still define the API.
A shared interface can organize your own code. It cannot make Gate's BTC_USDT and Kraken's XBTUSD into the same string. Request fields, auth, and stream events differ. If a workflow needs a field, leave it on the adapter instead of dropping it in a generic mapper.
A scalable integration is one you can extend without coupling the rest of the app to one exchange's API. That is an engineering property, not a latency promise. You should be able to add an adapter, test it on its own, and point at the module that owns retries, reconciliation, and rate limiting.
What the SDKs actually take off your plate
Raw HTTP is fine for a narrow prototype. It gets expensive when business logic builds the signature or edits the timestamp. That logic now depends on one exchange's auth rules, and the next exchange will not use them.
Use three boundaries:
- Workflows say what the app needs, such as "latest public ticker" or "apply this account update."
- The exchange adapter turns that into the client, method, and fields for one package.
- Shared controls cover concurrency, logging, reconciliation, and request scheduling. They must not assume every API returns the same error or the same limit headers.
Siebly SDKs belong in the adapter. Across these packages, a REST client will:
- attach the exchange's signature and timestamp
- accept typed parameters instead of an untyped query string
- throw or return the exchange's error payload so you can branch on it
It will not:
- retry a failed REST call
- wait because you are close to a rate limit
- pick a symbol format for you
Clock sync is also yours. Signing uses a timestamp, but automatic time sync is not a default. On Bybit it is off unless you set enable_time_sync. If private calls fail with a recvWindow or timestamp error, fix the host clock first. setTimeOffset and setTimeOffsetMs exist on several clients for a measured offset. They are not a substitute for NTP.
For system boundaries beyond a single client, see Siebly.io's system architecture research. Keep secrets in real configuration, use the smallest key permissions that still do the job, and leave withdrawal permission off on automation keys.
Raw calls, a wrapper, and an exchange SDK
| Approach | What you control | What you still write | What stays visible |
|---|---|---|---|
| Raw API calls | URL, headers, body, transport | Signing, timestamps, parsing, reconnects | The exchange contract, because your code speaks it |
| Shared wrapper | Policies you put in one place | Adapters, plus any policy the wrapper should not hide | Only if each adapter keeps exchange fields |
| Exchange SDK | The client surface for that exchange | Architecture, retries, rate limits, reconciliation | Method names, params, and response types for that API |
Direct calls fit a short experiment where the team wants the raw contract in the repo. An SDK fits once the same signing and socket code would be copied for a second workflow or a second exchange. Either way, read the exchange docs before you trust a method name.
Same public ticker, ten different clients
Public market data is the right first call. No key is required. The samples below are the public methods from each SDK's examples. Notice the client class, the argument names, and the symbol. Those three are the adapter.
Install the package you are actually calling. HTX and Kraken are scoped. The others are not.
Imported example
npm install binance bybit-api okx-api bitget-api kucoin-api gateio-api bitmart-api coinbase-api @siebly/htx-api @siebly/kraken-api
Binance spot uses MainClient. USD-M futures are USDMClient, COIN-M are CoinMClient, and portfolio margin is PortfolioClient. Do not send a COIN-M symbol such as ETHUSD_PERP through the spot client.
Imported example
import { MainClient } from "binance";
const client = new MainClient();
const ticker = await client.get24hrChangeStatistics({ symbol: "BTCUSDT" });
Bybit's V5 client needs a category. The same symbol string means different products.
Imported example
import { RestClientV5 } from "bybit-api";
const client = new RestClientV5();
const tickers = await client.getTickers({
category: "linear",
symbol: "BTCUSDT",
});
OKX uses instId, with a hyphen. Private calls also need an API passphrase (apiPass). Public calls do not.
Imported example
import { RestClient } from "okx-api";
const client = new RestClient();
const ticker = await client.getTicker({ instId: "BTC-USDT" });
Bitget V3 (unified trading) takes a category. V2 is a different client, RestClientV2, with different methods. Do not mix them in one adapter.
Imported example
import { RestClientV3 } from "bitget-api";
const client = new RestClientV3();
const tickers = await client.getTickers({
category: "SPOT",
symbol: "BTCUSDT",
});
KuCoin spot symbols use a hyphen. Futures are a separate FuturesClient. There is also a UnifiedAPIClient for the unified API. Pick the one that matches the account, not whichever file you opened first.
Imported example
import { SpotClient } from "kucoin-api";
const client = new SpotClient();
const ticker = await client.getTicker({ symbol: "BTC-USDT" });
Gate uses an underscore, and the field is currency_pair, not symbol.
Imported example
import { RestClient } from "gateio-api";
const client = new RestClient();
const ticker = await client.getSpotTicker({ currency_pair: "BTC_USDT" });
BitMart's public V3 ticker uses the same underscore style, on getSpotTickerV3. Older V1 and V2 ticker methods are still in the client. Call the version your adapter is written against.
Imported example
import { RestClient } from "bitmart-api";
const client = new RestClient();
const ticker = await client.getSpotTickerV3({ symbol: "BTC_USDT" });
Coinbase is several APIs in one package. Advanced Trade public products use product_id and a -USD pair. Exchange, International, Prime, App, and Commerce have their own clients. A key that works for one does not automatically work for the others. Advanced Trade wants the API key name plus the EC private key, or a parsed cdpApiKey object. Exchange, International, and Prime also use a passphrase.
Imported example
import { CBAdvancedTradeClient } from "coinbase-api";
const client = new CBAdvancedTradeClient();
const product = await client.getPublicProduct({ product_id: "BTC-USD" });
HTX spot symbols are lowercase and have no separator.
Imported example
import { SpotClient } from "@siebly/htx-api";
const client = new SpotClient();
const ticker = await client.getTicker({ symbol: "btcusdt" });
Kraken is the clearest warning against a shared symbol type. The REST public ticker wants XBTUSD. The spot WebSocket V2 ticker wants BTC/USD. Same market, two identifiers, and both are correct.
Imported example
import { SpotClient } from "@siebly/kraken-api";
const client = new SpotClient();
const ticker = await client.getTicker({ pair: "XBTUSD" });
Private clients are the same classes, with credentials. The option names are not shared:
| Package | REST credential fields |
|---|---|
| binance | api_key, api_secret. A private key as the secret selects RSA or Ed25519. |
| bybit-api | key, secret |
| okx-api | apiKey, apiSecret, apiPass |
| bitget-api | apiKey, apiSecret, apiPass |
| kucoin-api | apiKey, apiSecret, apiPassphrase |
| gateio-api | apiKey, apiSecret |
bitmart-api | apiKey, apiSecret, apiMemo |
| coinbase-api | apiKey, apiSecret, optional cdpApiKey. apiPassphrase where that API uses one. |
| @siebly/htx-api | apiKey, apiSecret |
| @siebly/kraken-api | apiKey, apiSecret |
Read those from the environment. Do not put live keys in source, logs, or a browser bundle.
Streams and awaitable WebSocket calls
Use REST when you want one response and you are done. Use a WebSocket when you want a stream. Some exchanges also let you send a command on a socket and await the reply. That second style is a WebSocket API. It is not a subscription.
Stream clients reconnect on their own and resubscribe. You still have to decide what your app does with a duplicate event, a gap, or a stale book. On reconnected, query REST for the account and market state you depend on, then resume. Do not treat the socket as a source of truth across a disconnect.
Topic arguments are not portable. Three public subscriptions from the SDK examples:
Imported example
import { WebsocketClient } from "binance";
const binanceWs = new WebsocketClient({ beautify: true });
binanceWs.on("formattedMessage", (data) => {
console.log(data);
});
await binanceWs.subscribe("btcusdt@miniTicker", "main");
Imported example
import { WebsocketClient } from "bybit-api";
const bybitWs = new WebsocketClient();
bybitWs.on("update", (data) => {
console.log(data);
});
bybitWs.subscribeV5("kline.5.BTCUSDT", "spot");
Imported example
import { WebsocketClient } from "okx-api";
const okxWs = new WebsocketClient();
okxWs.on("update", (data) => {
console.log(data);
});
okxWs.subscribe({
channel: "tickers",
instId: "BTC-USDT",
});
KuCoin spot V1 uses a path and a market key, for example client.subscribe('/market/ticker:BTC-USDT', 'spotPublicV1'). HTX uses topic strings plus a key from WS_KEY_MAP, such as market.btcusdt.ticker on WS_KEY_MAP.spotPublic. Kraken spot V2 passes a topic object and WS_KEY_MAP.spotPublicV2, with symbol: ['BTC/USD']. BitMart is client.subscribe('spot/ticker:BTC_USDT', 'spot'). Coinbase streams go through WebsocketClient as well, with that product's topic names.
If your adapter's subscribe() takes a string, it is already wrong for OKX and Kraken.
Where you can await a WebSocket call
WebsocketAPIClient wraps sendWSAPIRequest(). You call a method, and the promise resolves when the matching response arrives. The connection, login, and matching are inside the client. Your code still handles the result.
These packages export WebsocketAPIClient:
- binance
- bybit-api
- okx-api
- bitget-api (V3 / unified trading only; the V2 client does not support this)
- kucoin-api
- gateio-api
- @siebly/htx-api
- @siebly/kraken-api
BitMart does not. Coinbase's sendWSAPIRequest is a stub. The source says not to use it. Coinbase in this SDK is REST plus stream subscriptions.
A public Binance WebSocket API call, same shape as the SDK example, with no key:
Imported example
import { WebsocketAPIClient } from "binance";
const wsApi = new WebsocketAPIClient();
const book = await wsApi.getSpotOrderBook({ symbol: "BTCUSDT" });
Private commands exist on the same class, and on the other packages above. Names differ. Bybit's example awaits submitNewOrder with category, symbol, orderType, qty, side, and price. OKX's example awaits submitNewOrder with instId, tdMode, side, ordType, and sz, and the client is constructed with an accounts array of apiKey, apiSecret, and apiPass. Gate uses submitNewSpotOrder and fields such as currency_pair. KuCoin uses submitNewSpotOrder and needs apiPassphrase. Bitget's V3 client needs V3 keys and apiPass. HTX and Kraken expose their own spot order methods on WebsocketAPIClient.
Binance signs each WebSocket API command unless you use an Ed25519 key. HMAC and RSA still work. Ed25519 is what the SDK examples recommend when latency matters.
Do not send the same order object to two of these clients. Map it in the adapter, and keep a client order id that your app generated before the call. If the socket drops after you send and before you see a reply, query by that id. Do not fire the order again and hope.
Rate limits stay in your process
The SDKs do not throttle. They will send the next request as soon as you call them. Your process has to pace itself from the exchange's current docs. Limits change, and they are often per IP, per UID, or per endpoint weight. A shared wrapper is a reasonable place for that queue. The exchange client is not.
Two SDKs will show you headers. They still will not slow down.
Binance REST clients remember the rate-limit headers they have seen:
Imported example
import { MainClient } from "binance";
const client = new MainClient();
await client.getSymbolPriceTicker({ symbol: "BTCUSDT" });
console.log(client.getRateLimitStates());
Bybit can parse per-endpoint limit headers into the response. It is off by default:
Imported example
import { RestClientV5 } from "bybit-api";
const client = new RestClientV5({
parseAPIRateLimits: true,
});
Gate's getAccountRateLimit() and OKX's getAccountRateLimit() call an exchange endpoint and return that account's limit info. Calling them is not the same as enforcing a limit. You still decide when to wait, when to fail, and which errors are safe to retry. Reads can back off. Order writes need the client order id check first.
Build one adapter, then the next
Keep the first slice small. One public call, one exchange, tests around the adapter.
- Name the inputs, the output, and the failures. Include missing, delayed, duplicate, and stale data.
- Pick one package from the list above. Add the next exchange after this one has tests.
- Read that exchange's docs for the exact path, auth, stream, and limits. The SDK method is a typed wrapper. The docs still win if they disagree.
- Run the public sample. Then add a stream only if the workflow needs one. Add
WebsocketAPIClientonly if that package has it and you actually need an awaitable socket call. - For anything signed, use a testnet, demo, or paper environment, least-privilege keys, and no withdrawal permission.
Test the boundary, not only the happy response. Cover bad parameters, REST timeouts, and what your code does when it must not retry a write. For sockets, cover connect, events, disconnect, and reconnect. Record how you decide the local book or the local orders are current again.
Contract tests check what the app expects from every adapter. Adapter tests check this exchange's request, response, and errors. Shared tests only cover behavior that really is shared. "Has a ticker method" is not shared behavior if the arguments differ.
When you move an existing integration behind an adapter, move one path, then compare it with the old path under test. Check request construction, response parsing, errors, and state updates before you migrate the next path.
Before a dependency bump, read that package's changelog and the Siebly.io release notes. A new method is not a promise that the old symbol or the old client still means the same thing. Bitget V2 and V3 are the obvious case. Binance demo trading (demoTrading: true) and testnet are different environments too.
Siebly.io's JavaScript SDKs are the implementation layer for these exchanges in Node.js and TypeScript. They sign, type, and keep WebSocket connections up. Your application still owns architecture, REST retries, rate limits, and what happens after a reconnect. Explore the Siebly.io SDKs and start with one public call you can check against the exchange docs.
Frequently Asked Questions
How do you build a scalable crypto API integration in Node.js?
Start with one exchange and one use case. Put request construction in an adapter that uses that exchange's client. Verify the call against the official docs. Add tests for bad input, timeouts, socket reconnect, and the rate-limit behavior you implemented. Add the next exchange only after the first adapter's inputs, outputs, and failures are boring.
Should I call the exchange myself or use an SDK?
Call it yourself when you want the raw HTTP contract in your repo and the integration is small. Use the SDK when you would otherwise copy signing, timestamps, and socket lifecycle. The SDK does not remove the docs, and it does not remove your retry and rate-limit code.
Do these SDKs handle rate limits automatically?
No. They do not queue or delay requests. Binance exposes the last rate-limit headers through getRateLimitStates(). Bybit can parse limit headers when parseAPIRateLimits is true. Gate and OKX can query an account rate-limit endpoint. You still implement scheduling from the current exchange docs, and you do not try to get around those limits.
How should REST and WebSockets be split?
REST is request and response. WebsocketClient is for streams: you subscribe, and you handle update or formattedMessage events. WebsocketAPIClient is for commands you await. It exists on binance, bybit-api, okx-api, bitget-api (V3), kucoin-api, gateio-api, @siebly/htx-api, and @siebly/kraken-api. It is not in bitmart-api or coinbase-api. After a reconnect, reconcile with REST before you trust local state.
Can one interface cover several exchanges?
Yes, for the workflow. No, for the request. A shared function can mean "give me this market's ticker." The adapter underneath still has to pass symbol, instId, currency_pair, pair, or product_id, and it has to know which client class to construct. If a field matters downstream, keep it. Unsupported operations should fail in that adapter, not disappear inside a common type.
How should I store API credentials?
Keep them out of source, logs, prompts, and client bundles. Use secret storage that matches the deployment, and restrict each key to what that workflow needs. Turn off withdrawals on automation keys. Never paste live credentials into examples or tickets. Prefer test credentials, and make sure test output cannot print secrets.
Related articles
Continue from here