Messages
{
"op": "subscribed",
"id": "holders-tab",
"channel": "wallet-balances",
"updatePeriod": 0,
"watchlist": {
"id": "app-holders",
"version": 4183,
"wallets": 48210
}
}{
"op": "event",
"id": "holders-tab",
"channel": "wallet-balances",
"data": [
{
"wallet": "DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj",
"chain": "solana",
"token": "CBZq5ycLcr6mnYvjFTPuYSuhgS3ujsiTrT4sqatBpump",
"rawBalance": "15915963200000",
"decimals": 6,
"balance": "15915963.2",
"block": 371204455,
"logIndex": 81592322,
"txHash": "5hG8mJ3tX1qYw2Zk9uVb7nRc4pLs6dEa8fTy1oWq3xNv2KjH9gBm5rCz7eUi4sPa6tDl8yFo1wXn3vQk5jMh",
"publishedAt": 1790539767391
}
],
"asOf": 1790539767412,
"cursor": "v2.muk95908.muk95aov"
}No examples foundWallets
Wallet Balances Stream
The exact token balances of the wallets you follow, pushed as each transaction changes them, one message per transaction and wallet: one wallet, a list of up to 100, or a watchlist of thousands.
WS channel: wallet-balances
The exact token balances of the wallets you follow, pushed as each transaction changes them, with the new balance of every token the transaction moved. Follow one wallet (on one chain or on several), a list of up to 100, or a watchlist of thousands of wallets in one subscription.
Use it for a live holders tab of your own users, balance alerts, or any screen that shows what a large set of wallets holds. For positions with entry price and PnL, see the Wallet Positions Stream; for every trade, the Wallet Trades Stream.
Delivery model
- Events, as they land. There is no snapshot: the stream starts with the first balance change after your subscription. Take the starting balances from REST: token holders with
watchlist=<id>for a watchlist,/v1/wallet/positionsfor a wallet. - One message per transaction and wallet. Every message is
"data": [balance, ...]: the balance after the transaction of each token it moved for that wallet. A transaction that moves 5 tokens of a wallet is one message of 5 balances. When your connection falls behind, the balances waiting for it are merged (the latest per wallet, chain and token): a message can then carry several transactions, and costs 1 per wallet and chain it carries. - Absolute balances. Each balance is the wallet’s whole balance of the token after the transaction, never a difference: the latest one replaces what you had.
- Keep the greatest (
block,logIndex). For each wallet, chain and token, keep the balance with the greatestblock, then the greatestlogIndex. A balance that arrives late, or twice (after a resume), then changes nothing. - Tokens. SPL tokens on Solana and ERC-20 tokens on EVM chains. Native coins (SOL, ETH and the native coin of each chain), their wrapped versions and USD stablecoins are not on this channel: read them with
/v1/wallet/positions(includeNative=true,includeStables=true).
Parameters
Giveaddress with either chain or chains, or give wallets, or give watchlist.
string
Public chain id,
evm:<id> or solana. Example: solanastring | string[]
Several chains of the wallet in one subscription, in place of
chain: public chain ids, comma-separated ("evm:8453,evm:1") or as an array. evm stands for every EVM chain and combines with explicit ids. Every chain must belong to the wallet’s family: EVM chains for a hex address, solana for a base58 address. Send chain or chains, not both. Example: evmstring
The wallet address (EVM hex, case-insensitive; Solana base58). Example:
DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzjobject[]
A list of 1 to 100 wallets,
[{ "chain": "...", "address": "..." }, ...], in place of chain and address. Each entry takes one chain and counts as one subscription in the limit of your account. Each balance carries its wallet.string
The id of one of your watchlists, in place of
chain, chains, address and wallets. Every wallet of the list is followed, and the subscription counts as one in the limit of your account, whatever the size of the list. Wallets you add or remove over REST are followed or dropped within seconds, without subscribing again. Example: app-holdersinteger
Milliseconds, 0 to 60000; default
0 (every transaction as it lands, in its own message while your connection keeps up). Above 0, one message per period carries the latest balance of each wallet, chain and token that changed in the period (the greatest block, logIndex); a period that piles up more than 256 balances, or 256 KB of them, is sent at once and the next period starts, so no balance is left out. The ack carries the value applied (snapped up to 0, 100, 200, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000). "block" is not available on this channel. See Choosing your pace.boolean
default:"false"
true leaves out the balances of tokens flagged as spam. A token without a spam verdict is delivered. On a list, give it once, next to wallets.The balance
| Field | |
|---|---|
wallet | The wallet (EVM lowercased, Solana as is) |
chain | Public chain id |
token | The token address (EVM lowercased, the mint on Solana) |
rawBalance | The balance after the transaction, in the token’s smallest unit: an exact integer, as a string. Always present |
decimals | The token’s decimals, or null when not known yet |
balance | rawBalance scaled by decimals: an exact decimal string, never in exponent notation, the format of amount on /v1/wallet/positions. null when decimals is null |
block | The block (the slot on Solana) |
logIndex | The position of the change in its block: compare it, never read meaning into it |
txHash | The transaction (its signature on Solana) |
publishedAt | When the balance was published on our side, ms, or null. Not the block time: to order balances, use block and logIndex |
rawBalance and balance with a decimal or big-integer library, not a float, when the last digits matter: an 18-decimal balance does not fit in a float.
Resume
With a single wallet, on one chain or on several, or a watchlist, every message carries acursor. After a reconnect, subscribe again with "since": "<cursor of the last message you received>": you receive the balance changes after it (up to 5 minutes back), then live. You are never billed twice: anything up to your cursor that comes back is flagged "replay": true and free (on a watchlist, this can include a few messages from the seconds around your disconnection); what comes after it is billed like live messages, even when it carries "replay": true. Keep the greatest (block, logIndex) as usual: a balance you already applied changes nothing. On a watchlist, a wallet removed, or the list cleared, while you were away may not be replayed. If the replay is not available, a REPLAY_TOO_OLD notice gives details.restFromMs: the subscription keeps going; since balances are absolute, read the current ones again over REST (token holders with watchlist=<id>, /v1/wallet/positions) for the tokens you show.
A message flagged "gap": true follows balances that could not be delivered (for example when your connection fell behind): read the current balances over REST for the tokens you show, then keep applying messages.
A wallets list takes no since: after a reconnect, subscribe again and read the current balances over REST. See Reconnect & resume.
Billing
1 credit per wallet and chain in each message: one transaction of the wallet costs 1 at the default pace, however many of its tokens it moved, and a batch (updatePeriod) costs 1 per wallet and chain it carries. A wallet whose balances do not change costs nothing, and the connection is free. A wallet followed on several chains, or a chains: "evm" wallet of a watchlist, pays only the chain of each transaction. On a resume you are never billed twice: what comes back up to your cursor is free, and what comes after it is billed like live messages (see Resume). Details in Billing.
GET /v1/usage/breakdown reports this channel under WS wallet-balances, where requests is the number of billed messages.
Errors
| Code | Meaning | What to do |
|---|---|---|
INVALID_PARAM | A parameter was refused. details.param names it (chain, chains, address, wallets[3].address, watchlist, updatePeriod, excludeSpam, since) | Fix the subscription |
INVALID_CHAIN | Unknown chain id | Fix the subscription |
NOT_FOUND | The watchlist does not exist (details.reason: unknown, or purged after 48 hours without use). Sent on a subscribe, or later when the watchlist is deleted or purged: the subscription ends. details.purgedAt comes with purged on a subscribe; it can be missing when the watchlist is purged while you follow it | Create the watchlist, then subscribe again |
RATE_LIMITED | The subscription limit of your account is reached, or the list does not fit in what is left | Close subscriptions, send a shorter list, or ask us to raise the limit |
RATE_LIMITED with details.refused (watchlist, a notice) | Some wallets of the watchlist cannot be followed for now: details.refused is how many. The subscription stays open and the other wallets keep coming | Contact us |
UPSTREAM_ERROR with details.retryAfterMs | Wallet streams are starting | Send the same subscribe again after details.retryAfterMs |
UPSTREAM_ERROR with details.resumes: true | Live data is temporarily unavailable | Nothing: the subscription resumes by itself |
Close codes
| Code | Meaning |
|---|---|
4401 | Auth - key missing, invalid, or revoked. Live connections are dropped the moment a key is revoked. |
4402 | Credits or billing: the WebSocket mirror of REST 402 / 403. The close reason says what to do (see Errors). |
1012 | Server restart - reconnect after the retryAfterMs of the JSON reason, then subscribe again. |
1013 | Server at capacity - reconnect after the retryAfterMs of the JSON reason. |
1008 | Policy - subscription limits, flooding, or a blocked socket. Fix the cause before reconnecting. |
1001 | Nothing received from you for 60 s (idle timeout), or for 35 s while our WebSocket ping waits for its answer (no answer to pings). Send a ping every 15 to 30 s; after no answer to pings, reconnect and resume. |
1009 | A frame you sent is over 64 KB. |
{
"op": "subscribe",
"channel": "wallet-balances",
"id": "holders-tab",
"params": {
"watchlist": "app-holders"
}
}
{
"op": "subscribe",
"channel": "wallet-balances",
"id": "bal-1",
"params": {
"chain": "solana",
"address": "DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj"
}
}
{
"op": "subscribe",
"channel": "wallet-balances",
"id": "bal-evm",
"params": {
"chains": "evm",
"address": "0x95d955179a7cd45aeef394ed39f6a8d8b1bd1e09"
}
}
{
"op": "subscribe",
"channel": "wallet-balances",
"id": "followed",
"params": {
"wallets": [
{ "chain": "solana", "address": "DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj" },
{ "chain": "evm:8453", "address": "0x95d955179a7cd45aeef394ed39f6a8d8b1bd1e09" }
],
"excludeSpam": true
}
}
{
"op": "subscribed",
"id": "holders-tab",
"channel": "wallet-balances",
"updatePeriod": 0,
"watchlist": {
"id": "app-holders",
"version": 4183,
"wallets": 48210
}
}
{
"op": "event",
"id": "holders-tab",
"channel": "wallet-balances",
"data": [
{
"wallet": "DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj",
"chain": "solana",
"token": "CBZq5ycLcr6mnYvjFTPuYSuhgS3ujsiTrT4sqatBpump",
"rawBalance": "15915963200000",
"decimals": 6,
"balance": "15915963.2",
"block": 371204455,
"logIndex": 81592322,
"txHash": "5hG8mJ3tX1qYw2Zk9uVb7nRc4pLs6dEa8fTy1oWq3xNv2KjH9gBm5rCz7eUi4sPa6tDl8yFo1wXn3vQk5jMh",
"publishedAt": 1790539767391
}
],
"asOf": 1790539767412,
"cursor": "v2.muk95908.muk95aov"
}
{
"op": "event",
"id": "bal-evm",
"channel": "wallet-balances",
"data": [
{
"wallet": "0x95d955179a7cd45aeef394ed39f6a8d8b1bd1e09",
"chain": "evm:8453",
"token": "0x4ed4e862860bed51a9570b96d89af5e1b0efefed",
"rawBalance": "0",
"decimals": 18,
"balance": "0",
"block": 49964751,
"logIndex": 41,
"txHash": "0x3f1c9a2be7d4405c8e61f0a9b2c7d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0",
"publishedAt": 1790539782311
},
{
"wallet": "0x95d955179a7cd45aeef394ed39f6a8d8b1bd1e09",
"chain": "evm:8453",
"token": "0xb2000000000000000000007bf6d5cbb0e24cb301",
"rawBalance": "1113125600000000000000000",
"decimals": 18,
"balance": "1113125.6",
"block": 49964751,
"logIndex": 49,
"txHash": "0x3f1c9a2be7d4405c8e61f0a9b2c7d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0",
"publishedAt": 1790539782311
}
],
"asOf": 1790539782377,
"cursor": "v1.amun6v2l5lbes.2l1.mfq3c9k4"
}
{
"op": "error",
"id": "holders-tab",
"error": {
"code": "NOT_FOUND",
"message": "Watchlist \"app-holders\" not found",
"details": {
"watchlist": "app-holders",
"reason": "unknown"
}
}
}
Messages
{
"op": "subscribed",
"id": "holders-tab",
"channel": "wallet-balances",
"updatePeriod": 0,
"watchlist": {
"id": "app-holders",
"version": 4183,
"wallets": 48210
}
}{
"op": "event",
"id": "holders-tab",
"channel": "wallet-balances",
"data": [
{
"wallet": "DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj",
"chain": "solana",
"token": "CBZq5ycLcr6mnYvjFTPuYSuhgS3ujsiTrT4sqatBpump",
"rawBalance": "15915963200000",
"decimals": 6,
"balance": "15915963.2",
"block": 371204455,
"logIndex": 81592322,
"txHash": "5hG8mJ3tX1qYw2Zk9uVb7nRc4pLs6dEa8fTy1oWq3xNv2KjH9gBm5rCz7eUi4sPa6tDl8yFo1wXn3vQk5jMh",
"publishedAt": 1790539767391
}
],
"asOf": 1790539767412,
"cursor": "v2.muk95908.muk95aov"
}No examples foundAck
type:object
Server acknowledgement: the subscription is live.
Balances
type:object
Server push frame, one per transaction and followed wallet, data always an array of its token balances (a batch per period with updatePeriod): 1 credit per wallet and chain in the message (one transaction: 1, however many tokens it moved).
Subscribe
type:object
Client frame opening the subscription (additive, ack is explicit).