Watch a Solana wallet over WebSocket
Know when a Solana wallet changes, list the transactions behind it, and wait for finality with accountSubscribe, signatureSubscribe and a gap-fill read.
| Method / Endpoint | n/a — tutorial (wss://triport.io/ws/sol + POST https://triport.io/sol) |
| Network | Solana |
| Authentication | x-token: $TRIPORT_API_KEY on the upgrade request and on every HTTP call |
| Required scope | solana:rpc |
| Tier / rate limit | Every plan, from the 7-day Free trial (free) up; reads share the sol_read_rpc budget |
This tutorial builds a small wallet watcher. It is the design a deposit notifier or a wallet backend needs: a push channel that says something changed, a read that says what changed, and a cursor that makes a dropped socket harmless. Everything runs against live routes; the output samples come from a probe on 2026-09-22.
What you need
- An API key. Create one in the console. The
examples read it from
TRIPORT_API_KEY. - A plan.
accountSubscribeandsignatureSubscribeare in every plan's Solana WebSocket set, including the 7-day Free trial. Other subscriptions are gated higher:logsSubscribeandprogramSubscribeneed Basic, andblockSubscribeneeds Pro. This tutorial uses only the two that every plan has. - A read budget.
getSignaturesForAddressandgetTransactionboth count againstsol_read_rpc: 20 requests/s on the Free trial, 60 on Basic, 200 on Pro and 600 on Business, with a 2× burst (rate limits and tiers). - Node.js 18 or newer with the
wspackage (npm install ws). Node's built-infetchcovers the HTTP calls.
1. Subscribe from the command line
Before writing code, confirm the key and the subscription with wscat:
wscat -c "wss://triport.io/ws/sol" -H "x-token: $TRIPORT_API_KEY"
> {"jsonrpc":"2.0","id":1,"method":"accountSubscribe","params":["5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9",{"encoding":"base64","commitment":"confirmed"}]}The server first acknowledges with a numeric subscription id. After that it
pushes an accountNotification whenever the account's state changes at the
chosen commitment:
{"jsonrpc":"2.0","result":1,"id":1}
{"jsonrpc":"2.0","method":"accountNotification","params":{"result":{"context":{"slot":449504578},"value":{"lamports":1450455511477811,"data":["","base64"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":18446744073709551615,"space":0}},"subscription":1}}The notification carries the account's new state — its lamports, owner and data — and the slot. It does not name the transaction that caused the change. Several writes in one slot can arrive as a single notification, so the lamport difference between two notifications can be the sum of several transfers. That is why step 2 treats the notification as a trigger and reads the transactions separately.
2. Turn the trigger into a list of transactions
The watcher keeps one cursor: the newest signature it has fully processed.
Each notification, and each reconnect, runs a catch-up that asks
getSignaturesForAddress for
everything newer than that cursor (until), fetches each transaction with
getTransaction, and only then advances
the cursor.
Save this as watch-wallet.ts and run it with
npx tsx watch-wallet.ts <wallet>:
import WebSocket from "ws";
const KEY = process.env.TRIPORT_API_KEY!;
const WALLET = process.argv[2] ?? "5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9";
const RPC = "https://triport.io/sol";
const PAGE = 100;
interface SigInfo { signature: string; slot: number; err: unknown; blockTime: number | null }
let started = false; // false until the first catch-up has run
let lastSeen: string | undefined; // newest processed signature; persist it in production
async function rpc<T>(method: string, params: unknown[]): Promise<T> {
for (;;) {
const res = await fetch(RPC, {
method: "POST",
headers: { "content-type": "application/json", "x-token": KEY },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
if (res.status === 429) {
// Plan limit reached: wait as long as the server says, then retry.
const wait = Number(res.headers.get("retry-after") ?? "1");
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error}: ${body.message}`);
if (body.error) throw new Error(`RPC ${body.error.code}: ${body.error.message}`);
return body.result as T;
}
}
async function catchUp(): Promise<void> {
if (!started) {
// First run: start from "now" instead of replaying the wallet's history.
const [newest] = await rpc<SigInfo[]>("getSignaturesForAddress", [WALLET, { limit: 1 }]);
lastSeen = newest?.signature;
started = true;
return;
}
// Walk back from the newest signature until the cursor, a page at a time.
const fresh: SigInfo[] = [];
let before: string | undefined;
for (;;) {
const page = await rpc<SigInfo[]>("getSignaturesForAddress", [
WALLET,
{ limit: PAGE, until: lastSeen, before, commitment: "confirmed" },
]);
fresh.push(...page);
if (page.length < PAGE) break;
before = page[page.length - 1].signature;
}
// Process oldest first, and advance the cursor only after each one is handled.
for (const s of fresh.reverse()) {
const tx = await rpc<any>("getTransaction", [
s.signature,
{ encoding: "jsonParsed", maxSupportedTransactionVersion: 1, commitment: "confirmed" },
]);
if (tx === null) return; // not readable yet: stop here, the next trigger retries
const i = tx.transaction.message.accountKeys.findIndex((k: any) => k.pubkey === WALLET);
const delta = (tx.meta.postBalances[i] - tx.meta.preBalances[i]) / 1e9;
console.log(`${s.signature} slot=${s.slot} ok=${s.err === null} SOL change=${delta}`);
lastSeen = s.signature;
}
}
// Run catch-ups one at a time; a trigger that arrives mid-run schedules one more.
let running = false;
let again = false;
async function trigger(): Promise<void> {
if (running) { again = true; return; }
running = true;
try {
do { again = false; await catchUp(); } while (again);
} catch (e) {
console.error("catch-up failed:", (e as Error).message);
} finally {
running = false;
}
}
function connect(): void {
const ws = new WebSocket("wss://triport.io/ws/sol", { headers: { "x-token": KEY } });
let ping: NodeJS.Timeout | undefined;
ws.on("open", () => {
ws.send(JSON.stringify({
jsonrpc: "2.0", id: 1, method: "accountSubscribe",
params: [WALLET, { encoding: "base64", commitment: "confirmed" }],
}));
ping = setInterval(() => ws.ping(), 30_000);
void trigger(); // close the gap left by a previous connection
});
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.id === 1 && typeof msg.result === "number") {
console.log("subscribed, id", msg.result);
} else if (msg.method === "accountNotification") {
const { context, value } = msg.params.result;
console.log(`account changed at slot ${context.slot}, lamports ${value.lamports}`);
void trigger();
} else if (msg.error) {
console.error("server:", JSON.stringify(msg)); // error frame before a close
}
});
// A bad key is refused before the upgrade, with an HTTP status and a JSON body.
ws.on("unexpected-response", (_req, res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => console.error(`refused: HTTP ${res.statusCode} ${body}`));
});
ws.on("close", (code, reason) => {
clearInterval(ping);
console.log(`closed ${code} ${reason.toString()}`);
if ([4001, 4003, 4030].includes(code)) return; // key, plan or trial problem: retrying will not help
setTimeout(connect, 2_000);
});
ws.on("error", (e) => console.error("socket:", e.message));
}
connect();Expected output: one subscribed line, then account changed lines, and
after each catch-up one line per new transaction, oldest first. This excerpt
is from a run against a busy account on 2026-09-22:
subscribed, id 1
account changed at slot 449506827, lamports 8814763864917
account changed at slot 449506828, lamports 8814768910946
ZooBgrSopWEu5hbzc5Vgsju5CVtxoj4WzjBVv52DtG7bTFvq2kT4gUHJ1FCjASgnfze1P11UTXkBf2VcJWwe7Ph slot=449506796 ok=true SOL change=0.000045939
4XVTGtvHzkZRhaE2DjbvFbBqnyhFcYhPWDd3DzinBbNd2g8rgbhndhMCfPHUQ4P4ckw96J4LjhF7nsd7n4xwD1UC slot=449506796 ok=true SOL change=0.002934474On a busy account, new notifications keep arriving while a catch-up is still running.
trigger() folds them into one more pass instead of starting a pass per
notification.
Set maxSupportedTransactionVersion to 1. Version 1 transactions are on
mainnet: the same run met legacy, 0 and 1 transactions within a few
slots. With the value 0, getTransaction on a version 1 transaction
returns this error object instead of a result, and the catch-up stops on it
every time:
{"code":-32015,"message":"Transaction version (1) is not supported by the requesting client. Please try the request again with the following configuration parameter: \"maxSupportedTransactionVersion\": 1"}Three details in that code carry the design:
- The cursor moves after the work, never before. If the process dies
between handling a transaction and saving the cursor, the next run handles it
again. Make
handleidempotent by signature and a replay is harmless. - Reconnect equals catch-up. Subscriptions end with their socket and
nothing is replayed. The
openhandler therefore callstrigger()itself, so whatever happened while you were disconnected is read from history. This is the pattern described in why a WebSocket subscription goes silent. untilbounds the walk from the old side. Paging withbeforecovers a burst of more than one page between triggers. The mechanics ofbeforeanduntilare covered in paging getSignaturesForAddress.
3. Wait for one transaction to finalize
The watcher reads at confirmed. To credit a deposit only once it is final,
follow that one signature with
signatureSubscribe at finalized:
import WebSocket from "ws";
export function waitFinalized(signature: string, timeoutMs = 120_000): Promise<unknown> {
return new Promise((resolve, reject) => {
const ws = new WebSocket("wss://triport.io/ws/sol", {
headers: { "x-token": process.env.TRIPORT_API_KEY! },
});
const timer = setTimeout(() => { ws.close(); reject(new Error("no notification")); }, timeoutMs);
ws.on("open", () => ws.send(JSON.stringify({
jsonrpc: "2.0", id: 1, method: "signatureSubscribe",
params: [signature, { commitment: "finalized" }],
})));
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.method === "signatureNotification") {
clearTimeout(timer);
ws.close();
resolve(msg.params.result.value.err); // null means it succeeded
}
});
ws.on("error", (e) => { clearTimeout(timer); reject(e); });
});
}The probe subscribed to an already-finalized signature and got the ack and then one notification:
{"jsonrpc":"2.0","result":2,"id":2}
{"jsonrpc":"2.0","method":"signatureNotification","params":{"result":{"context":{"slot":449504545},"value":{"err":null}},"subscription":2}}A signature subscription fires once and is then removed by the server, so
there is nothing to unsubscribe. A signature the node has never seen never
produces a notification, which is why the helper has a timeout; when it fires,
fall back to getTransaction to find out whether the transaction exists.
4. Errors you will actually see
A missing or wrong key is refused before the WebSocket upgrade, as HTTP 401 with a JSON body — not as a close code. The same body comes back from the HTTP endpoint:
{"error":"unauthorized","message":"Missing or unknown API key. Pass your key as ?api-key=<key> or the header Authorization: Bearer <key>. Create or copy a key at https://triport.io/app/keys","keys_url":"https://triport.io/app/keys"}A subscription above your plan, for example blockSubscribe on a Basic
key, gets a JSON error frame with current_tier and required_tier, and then
close code 4003. The watcher stops reconnecting on 4001, 4003 and 4030
because repeating the same request cannot succeed. The close codes are listed
in the Solana pub/sub reference.
Too many reads per second returns HTTP 429 with a Retry-After header. On
a Free-trial key the body is:
{
"error": "rate_limited",
"message": "Rate limit reached: your free plan allows 20 requests/s for sol_read_rpc. Retry in 1s (see the Retry-After header) or upgrade for a higher limit at https://triport.io/app/pricing",
"current_tier": "free",
"category": "sol_read_rpc",
"limit_rps": 20,
"burst_capacity": 40,
"retry_after_sec": 1,
"upgrade_url": "https://triport.io/app/pricing",
"docs_url": "https://triport.io/errors/http-429-rate-limit"
}The rpc() helper above already waits for Retry-After and retries. A very
busy wallet on a small plan can still outrun the budget, because each change
costs one getSignaturesForAddress call plus one getTransaction call per
new signature. If that happens, batch the triggers — for example, run the
catch-up at most once per second.
What this does not do
- It does not see token deposits. SPL token balances live in token accounts, not in the wallet account. To watch USDC arriving, subscribe to the wallet's associated token account as well (associated token account).
- It is not exactly-once delivery. The guarantee comes from the cursor and an idempotent handler, not from the socket.
- It does not replay history. On the first run it starts from the newest signature. To import older history, use the backfill tutorial.
- It does not reach past the node's history window. If
getSignaturesForAddresscomes back empty for an address you know is active, see empty address history. - It does not choose the commitment for you.
confirmedis used to trigger reads, andfinalizedis used before crediting; see commitment levels.
Related
- Reference:
accountSubscribe,signatureSubscribe,getSignaturesForAddress,getTransaction, and the/ws/solchannel. - Concepts: pub/sub subscription, pagination cursor.
- Network: Solana docs overview and the Solana RPC page.
- Plans: pricing.