Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 71 additions & 17 deletions src/node-binance-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default class Binance {
combineStreamDemo = `wss://demo-stream.binance.com/stream?streams=`;
wsApi = `wss://ws-api.binance.${this.domain}:443/ws-api/v3`;
wsApiTest = `wss://ws-api.testnet.binance.vision/ws-api/v3`;
wsApiDemo = `wss://demo-ws-api.binance.com/ws-api/v3`;

verbose = false;

Expand Down Expand Up @@ -285,6 +286,7 @@ export default class Binance {
}

getWsApiUrl() {
if (this.Options.demo) return this.wsApiDemo;
if (this.Options.test) return this.wsApiTest;
return this.wsApi;
}
Expand Down Expand Up @@ -741,12 +743,10 @@ export default class Binance {
*/
async signedRequest(url: string, data: Dict = {}, method: HttpMethod = 'GET', noDataInSignature = false) {
this.requireApiSecret('signedRequest');
const isListenKeyEndpoint = url.includes('v3/userDataStream');

let query = method === 'POST' && noDataInSignature ? '' : this.makeQueryString(data);

let signature = undefined;
if (!noDataInSignature && !isListenKeyEndpoint) {
if (!noDataInSignature) {
data.timestamp = new Date().getTime();

if (this.timeOffset) data.timestamp += this.timeOffset;
Expand Down Expand Up @@ -1873,6 +1873,11 @@ export default class Binance {
throw new Error(`futuresSubscribe: cannot combine '${category}' stream "${streams[0]}" with '${mismatchCategory}' stream "${mismatch}" on one connection. Binance routes futures streams to separate /public, /market and /private endpoints; subscribe to each category separately.`);
}
const baseUrl = this.getFStreamUrl(category);
// Private combined streams use ?listenKey=<k1>&listenKey=<k2> query params
// instead of ?streams=<a>/<b>
const wsUrl = category === 'private'
? baseUrl.replace(/\?streams=$/, '?') + streams.map(k => 'listenKey=' + k).join('&')
: baseUrl + queryParams;
let ws: any = undefined;
if (socksproxy) {
socksproxy = this.proxyReplacewithIp(socksproxy);
Expand All @@ -1882,14 +1887,14 @@ export default class Binance {
host: this.parseProxy(socksproxy)[1],
port: this.parseProxy(socksproxy)[2]
});
ws = new WebSocket(baseUrl + queryParams, { agent });
ws = new WebSocket(wsUrl, { agent });
} else if (httpsproxy) {
if (this.Options.verbose) this.Options.log(`futuresSubscribe: using proxy server ${httpsproxy}`);
const config = url.parse(httpsproxy);
const agent = new HttpsProxyAgent(config);
ws = new WebSocket(baseUrl + queryParams, { agent });
ws = new WebSocket(wsUrl, { agent });
} else {
ws = new WebSocket(baseUrl + queryParams);
ws = new WebSocket(wsUrl);
}

ws.reconnect = this.Options.reconnect;
Expand Down Expand Up @@ -3942,9 +3947,10 @@ export default class Binance {
/**
* Ensures a WebSocket API connection is open for the given connectionId
* @param {string} connectionId - connection identifier
* @param {function} messageHandler - handler for event messages when a new connection is created
* @return {promise} - resolves when the connection is open
*/
private ensureWsApiConnection(connectionId: string): Promise<void> {
private ensureWsApiConnection(connectionId: string, messageHandler: Callback = () => {}): Promise<void> {
return new Promise((resolve, reject) => {
const existing = this.wsApiConnections[connectionId];
if (existing) {
Expand All @@ -3958,7 +3964,7 @@ export default class Binance {
return;
}
}
const ws = this.connectWsApi(connectionId, () => {}, () => {});
const ws = this.connectWsApi(connectionId, messageHandler, () => {});
ws.once('open', () => resolve());
ws.once('error', (err: Error) => reject(err));
});
Expand Down Expand Up @@ -4378,20 +4384,68 @@ export default class Binance {
return res;
}

/**
* Opens the spot user data stream by subscribing over the WebSocket API.
* POST /api/v3/userDataStream was removed by Binance on 2026-02-20; user data
* streams are now started with the userDataStream.subscribe.signature WS-API method.
* Events are routed to the callbacks configured via userData()/Options.
* @return {promise} - resolves with { subscriptionId }
*/
async spotGetDataStream(params: Dict = {}) {
return await this.privateSpotRequest('v3/userDataStream', params, 'POST', true);
const connectionId = 'userData';
await this.ensureWsApiConnection(connectionId, this.userDataHandler.bind(this));
const timestamp = Date.now();
const query = `apiKey=${this.APIKEY}&timestamp=${timestamp}`;
const signature = this.generateSignature(query);
const result = await this.sendWsApiRequest(connectionId, 'userDataStream.subscribe.signature', {
apiKey: this.APIKEY,
timestamp: timestamp,
signature: signature,
...params
});
this.Options.userDataSubscriptionId = result.subscriptionId;
return result;
}

async spotKeepDataStream(listenKey: string | undefined = undefined, params: Dict = {}) {
listenKey = listenKey || this.Options.listenKey;
if (!listenKey) throw new Error('A listenKey is required, either as an argument or in this.Options.listenKey');
return await this.privateSpotRequest('v3/userDataStream', { listenKey, ...params }, 'PUT');
/**
* Kept for backwards compatibility: PUT /api/v3/userDataStream was removed by
* Binance on 2026-02-20 and WS-API subscriptions need no keepalive — they live as
* long as the connection. This only verifies the connection is still open.
*/
async spotKeepDataStream() {
const ws = this.wsApiConnections['userData'];
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('spotKeepDataStream: no open user data stream connection, start one with userData() or spotGetDataStream()');
}
if (this.Options.verbose) this.Options.log('spotKeepDataStream: keepalive is no longer required, WS-API subscriptions live as long as the connection');
return {};
}

async spotCloseDataStream(listenKey: string | undefined = undefined, params: Dict = {}) {
listenKey = listenKey || this.Options.listenKey;
if (!listenKey) throw new Error('A listenKey is required, either as an argument or in this.Options.listenKey');
return await this.privateSpotRequest('v3/userDataStream', { listenKey, ...params }, 'DELETE');
/**
* Closes the spot user data stream by unsubscribing over the WebSocket API.
* DELETE /api/v3/userDataStream was removed by Binance on 2026-02-20; user data
* streams are now closed with the userDataStream.unsubscribe WS-API method.
* @param {number} subscriptionId - optional subscription to close; defaults to the
* subscription created by userData(). When neither is available, all subscriptions
* on the connection are closed. The WebSocket connection itself is terminated once
* the tracked subscription (or all subscriptions) has been closed.
*/
async spotCloseDataStream(subscriptionId: number | undefined = undefined, params: Dict = {}) {
const connectionId = 'userData';
const ws = this.wsApiConnections[connectionId];
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('spotCloseDataStream: no open user data stream connection, start one with userData()');
}
const tracked = this.Options.userDataSubscriptionId;
subscriptionId = subscriptionId ?? tracked;
const requestParams: Dict = { ...params };
if (subscriptionId !== undefined) requestParams.subscriptionId = subscriptionId;
const result = await this.sendWsApiRequest(connectionId, 'userDataStream.unsubscribe', requestParams);
if (subscriptionId === undefined || subscriptionId === tracked) {
this.Options.userDataSubscriptionId = undefined;
this.terminateWsApi(connectionId, false);
}
return result;
}

// /**
Expand Down
61 changes: 61 additions & 0 deletions tests/binance-ws-api-userdata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,17 @@ describe('WebSocket API JSON-RPC', function () {
}
}).timeout(35000);
});

describe('spotCloseDataStream', function () {
it('should reject when no user data stream connection is open', async function () {
try {
await binance.spotCloseDataStream();
assert.fail('Should have thrown an error');
} catch (error: any) {
assert(error.message.includes('no open user data stream connection'), 'Should indicate the connection is missing');
}
});
});
});

describe('WebSocket API Live Tests', function () {
Expand Down Expand Up @@ -504,6 +515,56 @@ describe('WebSocket API Live Tests', function () {
);
});

it('should close the user data stream with spotCloseDataStream', function (done) {
this.timeout(TIMEOUT);

binance.websockets.userData(
(data) => {
console.log('User data event:', data);
},
undefined,
undefined,
async (endpoint) => {
try {
assert((binance as any).Options.userDataSubscriptionId !== undefined, 'Should have subscription ID');

const result = await binance.spotCloseDataStream();
console.log('Unsubscribed:', result);

assert((binance as any).Options.userDataSubscriptionId === undefined, 'Subscription ID should be cleared');
assert((binance as any).wsApiConnections['userData'] === undefined, 'Connection should be removed');
done();
} catch (error: any) {
stopWsApiConnections();
done(error);
}
}
);
});

it('should manage the stream lifecycle with spotGetDataStream/spotKeepDataStream/spotCloseDataStream', async function () {
this.timeout(TIMEOUT);

try {
const subscription = await binance.spotGetDataStream();
console.log('Subscribed:', subscription);
assert(subscription !== null, WARN_SHOULD_BE_NOT_NULL);
assert(subscription.subscriptionId !== undefined, WARN_SHOULD_HAVE_KEY + 'subscriptionId');
assert((binance as any).Options.userDataSubscriptionId === subscription.subscriptionId, 'Subscription ID should be tracked');

const keepAlive = await binance.spotKeepDataStream();
assert(keepAlive !== null, WARN_SHOULD_BE_NOT_NULL);

const result = await binance.spotCloseDataStream();
console.log('Unsubscribed:', result);
assert((binance as any).Options.userDataSubscriptionId === undefined, 'Subscription ID should be cleared');
assert((binance as any).wsApiConnections['userData'] === undefined, 'Connection should be removed');
} catch (error) {
stopWsApiConnections();
throw error;
}
});

it('should receive execution and balance events when creating a market order', function (done) {
this.timeout(TIMEOUT);

Expand Down
13 changes: 13 additions & 0 deletions tests/ws-endpoints-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,19 @@ describe('futuresSubscribe rejects mixed-category combined streams', function ()
});
});

describe('futuresSubscribe combined private streams', function () {
it('uses listenKey query params for multiple listenKeys', function () {
const k1 = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd';
const k2 = k1.split('').reverse().join('');
const ws: any = binance.futuresSubscribe([k1, k2], () => { });
try {
assert.equal(ws.url, `wss://fstream.binance.com/private/stream?listenKey=${k1}&listenKey=${k2}`);
} finally {
ws.terminate();
}
});
});

describe('Live: production market stream (aggTrade via /market/)', function () {
let trade;
let cnt = 0;
Expand Down
Loading