TON Mempool Stream

TONNode's mempool stream is a real-time feed of TON external messages — the signed messages wallets send to the network — as our node receives them from the public overlay, before they land in a block. You open one WebSocket, send a subscription, and get one JSON object per message: the raw BoC, its hashes, the destination wallet, and a best-effort decoding of common wallet bodies (transfers, jetton transfers, DEX swaps). After a reconnect you can replay the last ~60 seconds you missed.

Before you automate anything on it, read Data quality. Coverage is partial, a share of pending messages is never included in a block, and early messages are delivered before any signature check and are marked "unverified": true.

Stream wss://mempool.tonnode.io/v1/stream
Browser tickets POST https://mempool.tonnode.io/v1/ticket
Protocol WebSocket, one JSON object per text message, every message carries "v":1
Auth Authorization: Bearer tnmp_… from a server, or a one-time ticket from a browser
Filters destination wallet, outgoing recipient, op code, DEX pool
Replay the last ~60 s (at most 64 MiB) after a reconnect, with resume
Delay none — messages are passed on as the stream server receives them
Plans from $5 for 7 days, priced by simultaneous connections

Plans and keys

A plan is a period and a number of simultaneous connections on one stream key. Nothing else is metered: no message quota, filters and replay are included, and there is no added delay.

Plan Period Simultaneous connections Price
Test 7 days 1 $5
Start 30 days 1 $15
Pro 30 days 3 $30
Business 30 days 10 $100
More than 10 connections — 11+ on request, through support

Plans are bought in the console under Mempool, from the same balance that pays for node plans. There is no free tier.

  • One key per account. The first purchase issues the key and shows it once. TONNode stores only a hash of the secret, so it can never be shown again.
  • Renewing keeps the key. Buying a plan while you hold a key keeps the same key, so nothing in your deployment changes. A plan with the same number of connections adds its days to the time left (or to now, if the key has expired).
  • Changing plans converts the time left. The plan you buy sets the key's connection limit at once, for all of its time, so the time left is first converted at the two plans' prices per day, and then the new plan's days are added. Moving up shortens it: 20 days of Start ($0.50 a day, $10 of value) become 3 days of Business ($3.33 a day), plus Business's 30 days. Moving down lengthens it, and lowers the connection limit for all of it. The console shows the new end date before you pay.
  • Limits arranged with support. If support set your key above what a plan gives, renew it through support while that time runs; the console does not sell a plan on top of it.
  • Rotation. If a key leaks or is lost, rotate it in the console: you get a new secret with the same plan, limit and expiry. The old key stops at once and its open connections are closed. A key can be rotated up to 3 times an hour and 10 times a day.
  • Activation. A new, renewed or rotated key becomes usable within about 15 seconds.
  • Connection limit. When all of a key's connections are open, the next one is refused with rate_limit and close code 4429.

The key looks like tnmp_<key_id>_<secret>: key_id is 12 hex characters and identifies the key (it is what the console and the hello message show), the secret is 64 hex characters. Keep the key on a server; never ship it in browser or mobile code — browsers use tickets.


Connect

From a server

Send the key in the Authorization header of the WebSocket upgrade:

HTTP
GET /v1/stream HTTP/1.1
Host: mempool.tonnode.io
Connection: Upgrade
Upgrade: websocket
Authorization: Bearer tnmp_0123456789ab_000102…1e1f

Credentials in the query string are not supported.

From a browser

A browser cannot set headers on a WebSocket. It opens the connection with a one-time ticket instead. Your backend, which holds the key, asks for one:

HTTP
POST https://mempool.tonnode.io/v1/ticket
Authorization: Bearer tnmp_…
JSON
{"v":1,"ticket":"tnmt_5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a","expires_at":1791322908,
 "subprotocols":["tnmp.v1","tnmp.ticket.tnmt_5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a"]}

and the page passes subprotocols verbatim: new WebSocket(url, subprotocols). A ticket is valid for 30 seconds and one connection, so get a fresh one for every connect and reconnect. At most 100 unused tickets per key may be outstanding. The ticket endpoint allows CORS, so a page can call it directly with the key — fine for an internal tool, but on anything public it hands your key to every visitor.

Session

Text
server → hello
client → resume            (optional, only before the first subscribe)
client → subscribe
server → subscribed
server → msg | gap …        (in seq order)
client → subscribe          (any time: replaces the filters, no replay)
client → ping / server → pong (any time)

Nothing is delivered before the first subscribe. The server sends a WebSocket ping every 20 s and closes a connection it has heard nothing from for 60 s; standard client libraries answer pings on their own. When a connection drops, reconnect with exponential backoff (start at 0.5 s, cap at 30 s, add jitter) and resume.

The first message on every connection is hello:

JSON
{"v":1,"type":"hello","contract":"1.1","epoch":"9f3c1a7be2d04c11","head_seq":123456,"oldest_seq":118020,
 "ts_gw_us":1791322891600000,"key_id":"0123456789ab",
 "limits":{"max_conn":3,"max_filters":null,"delay_ms":0,"replay_limit":null}}

head_seq is the last sequence number assigned, oldest_seq the oldest one still held for replay, and limits the key's effective limits (null means unlimited).


Subscribe and filters

JSON
{"v":1,"type":"subscribe",
 "filters":{"dest":["0:2a18ac1734f34579e6a6c2a9e738bb5799d21e7a73ef106895c00c34549a9167"],
            "out_dest":["EQB3ncyBUTjZUA5EnFKR5_EnOMI9V1tTEAAPaiU71gc4TiUt"],
            "op":["0x0f8a7ea5"],
            "pool":["0:a0d1bc3a139cbc6b39a3e3633f74476be441343f9d0fd88b949fca0327b65a75"]},
 "include_unverified":false}
Filter Matches
dest the external message's destination — the sending wallet
out_dest any parsed.out[].dest — where the wallet is sending to
op any parsed.out[].op — "0x" followed by 1–8 hex digits
pool any parsed.out[].dex.pool
  • Entries within a list and the lists themselves are OR-ed. "filters":{} is the whole stream.
  • Addresses can be raw (0:…) or the 48-character user-friendly form; they are normalised to raw.
  • out_dest, op and pool can only match messages that have parsed.
  • A subscription holds at most 10,000 filter entries. Over that, the server answers error too_many_filters and keeps the previous subscription; an invalid entry gets error bad_request, also keeping the previous one. Both errors are non-fatal.
  • A later subscribe replaces the filters atomically, without replay. At most 10 subscribe per 10 s per connection.

The server confirms with the number of distinct entries in effect and where delivery starts:

JSON
{"v":1,"type":"subscribed","filters":4,"include_unverified":false,"from_seq":123451}

Early (unverified) messages

Every message arrives on one of two paths:

  • "path":"checked", "unverified":false — the node wrote it after its broadcast checks and de-duplication. Checked is not valid: the validator has not executed it yet.
  • "path":"early", "unverified":true — written before the node's broadcast signature check, at a point where any overlay peer can inject bytes.

Early messages are delivered by default. To receive checked messages only, put "include_unverified":false in the first subscribe of the connection. When the field is absent, the first subscribe uses the default (true) and a later subscribe keeps whatever is in effect; subscribed echoes the value.

Early messages may be garbage or forgeries. Anything that acts automatically on the stream — trading, sniping, alerts that move money — must look at unverified and either ignore unverified:true messages or confirm them first: wait for the checked copy with the same cell_hash, or confirm on chain. With early messages on, the same cell_hash usually arrives twice: first early, then checked.


Message fields

A real mainnet message — a v4r2 wallet sending 50,000,000 jetton units to the STON.fi v1 router for a swap (epoch, seq and the timestamps are illustrative):

JSON
{"v":1,"type":"msg","epoch":"9f3c1a7be2d04c11","seq":123456,
 "ts_node_us":1791322891482113,"ts_gw_us":1791322891503407,
 "boc":"te6cckECBAEAAVUAAeGIAFQxWC5p5orzzU2FU85xdq8zpDz0594g0SuAGGipNSLOB7z7QMUV59oCWxVn53cDBK/Ybxi+GoEjqPHLcC+fXbQCYeqmIcanyqPs/5OAt/hG6HYhZ3NqEZqmTz1Fj1tHUAlNTRi7VitY4AAHOzgADAEBaGIAVjFm774yMmB6BL/Il5hbRtPkPw4x11PgvVI0Bdo6vuYgpuScAAAAAAAAAAAAAAAAAAECAbAPin6lasVrDVlsH/9AL68ICADvO5kConGyoByJOKUjz+JOcYR6rramIAAe1Ep3rA5wnQAKhisFzTzRXnmpsKp5zi7V5nSHnpz7xBolcAMNFSakWcgjw0YBAwCZJZOFYYAKdbrk697uQeRUm/diojQYsc971Seu+rZ8qyEAmbxZCEtZ5WY4BwAKhisFzTzRXnmpsKp5zi7V5nSHnpz7xBolcAMNFSakWdAwKqiS",
 "size":357,
 "cell_hash":"c296499f76b7d670e8b880d697daa407dce698ec4866fa611d32d052bacc0fa3",
 "norm_hash":"c59a7c88a4b4d8e8fa8499960be4d784235c58953fbccf3ca5ff897da1f24e35",
 "sha256":"43da45c22cc6596c43e9203bdcfc89c1155bb1b008af8592d26fc1f5946ef96a",
 "dest":"0:2a18ac1734f34579e6a6c2a9e738bb5799d21e7a73ef106895c00c34549a9167",
 "unverified":false,"path":"checked",
 "parsed":{"wallet":"v4r2","valid_until":1791322908,"seqno":59239,
   "out":[{"dest":"0:ac62cddf7c6464c0f4097f912f30b68da7c87e1c63aea7c17aa4680bb4757dcc","value":"350000000","mode":1,
           "op":"0x0f8a7ea5",
           "jetton":{"amount":"50000000","to":"0:779dcc815138d9500e449c5291e7f12738c23d575b5310000f6a253bd607384e"},
           "dex":{"kind":"stonfi_swap"}}]}}
Field Meaning
epoch, seq Position in the stream, see Gaps and resume. seq grows by exactly 1 per event, so a filtered subscription sees jumps — that is not loss.
ts_node_us The node's clock when it wrote the message, Unix microseconds.
ts_gw_us The stream server's clock when it received it. The two clocks are on different hosts, so the difference is approximate.
boc / size The bag of cells exactly as the node received it, standard base64; size is its length in bytes.
cell_hash Hash of the root cell — what explorers call the message hash, and the in_msg hash of the transaction if it is included.
norm_hash The TEP-467 normalized hash, stable when the message is re-serialised or its src, import_fee or init change.
sha256 SHA-256 of the BoC bytes.
dest The destination wallet, raw form <workchain>:<64 hex>.
unverified, path See Early (unverified) messages.
parsed Present only when the body layout is recognised.

parsed is decoded from the message body alone — no chain state is read:

  • wallet — the body layout: v3r2, v4r2, v5r1, highload_v2 or highload_v3. Wallets with identical bodies share a label (v3r1 shows as v3r2, v4r1 as v4r2), and a look-alike contract can be labelled wrongly.
  • valid_until (Unix seconds) and seqno (absent for highload wallets).
  • out[] — the internal messages the wallet asks to send, in order, at most 255 (out_truncated:true when there are more): dest, value in nanotons as a decimal string, mode, and op when the body has one.
    • jetton for TEP-74 transfers (op 0x0f8a7ea5): amount in jetton units, to = the new owner.
    • dex for recognised swaps: kind is dedust_swap, dedust_v2_swap, stonfi_swap or stonfi_v2_swap; pool is present for DeDust, absent for STON.fi, whose messages do not name the pool.

Amounts are decimal strings; timestamps ending in _us are microseconds; integers stay below 2^53. Ignore fields and message types you do not know — new ones may be added.


Gaps and resume

epoch identifies one run of the stream server; a restart starts a new epoch with seq from 1 and closes connections with code 1001. Within an epoch every event — a message or a gap — gets the next seq, and events arrive in seq order.

A gap names sequence numbers you will not get on this connection. It is delivered to every subscription, whatever its filters:

JSON
{"v":1,"type":"gap","epoch":"9f3c1a7be2d04c11","from_seq":130001,"to_seq":130460,"reason":"slow"}
reason Meaning
slow This connection fell more than 5 s (or 8 MiB) behind; the events were skipped for it. Reconnecting with resume may recover them while they are still held.
source_reconnect Messages were lost before the stream server (the node's socket or the relay reconnected). Occupies exactly one seq; lost is the count when known.
ring The resume point is older than what is held for replay.

To resume, remember the epoch and the last seq you handled (a message's seq, or a gap's to_seq), reconnect, and send resume before the first subscribe:

JSON
{"v":1,"type":"resume","epoch":"9f3c1a7be2d04c11","last_seq":123450}
  • Same epoch, still held: replay from last_seq+1, then live, no gap.
  • Same epoch, too old: a ring gap, then replay from the oldest event held.
  • Different epoch (the server restarted): replay from the start of the new epoch, with a ring gap if that is no longer held; whatever the old epoch had after last_seq is lost.

Replay goes through the new subscription's filters. The server holds the last 60 seconds of events, at most 64 MiB. The same cell_hash can come back after a reconnect or a restart, so make your handling idempotent by cell_hash.

Ping

For clients that cannot send WebSocket pings, an application-level ping (at most one per second, id up to 64 bytes):

JSON
{"v":1,"type":"ping","id":"42"}
JSON
{"v":1,"type":"pong","id":"42","ts_gw_us":1791322891600000}

Errors and close codes

Errors arrive as a message; fatal:true is followed by a close.

JSON
{"v":1,"type":"error","code":"too_many_filters","message":"12000 entries > limit 10000","fatal":false}
code Fatal HTTP Close When What to do
unauthorized yes 401 4401 missing, malformed or unknown key or ticket fix the key; do not retry in a loop
revoked yes 403 4403 the key was rotated or revoked use the current key from the console
suspended yes 403 4403 the key is on hold, or its plan has ended check the key in the console: renew it if it has expired, otherwise contact support
expired yes 403 4403 the plan ended (seen for up to about 15 s after the end; after that the key reports suspended) renew in the console; the same key comes back
feed_disabled yes 503 4503 the stream is switched off for maintenance back off and reconnect
internal yes 500 1011 server fault back off and reconnect
rate_limit no¹ 429 4429 all connections of the key are in use, or too many tickets, subscribes or pings close an idle connection or slow down
too_many_filters no 400 — over 10,000 filter entries send fewer; the previous subscription stays
bad_request no² 400 4400 malformed or out-of-order command fix the message

¹ At connect time a rate_limit refusal ends the connection. ² After 10 bad_request errors the connection is closed with 4400.

Other close codes: 1000 normal, 1001 server restart (new epoch — reconnect and resume), 4408 the connection was too slow to read (a write stalled for 30 s, or nothing was read from it for 60 s). Authentication failures normally arrive as an error followed by a close, but the server may also refuse the upgrade with the HTTP status above — handle both. Never reconnect in a tight loop after 4401 or 4403.


Data quality

Read this before you build on the stream.

  • Coverage is partial. The stream carries what our node receives from the public overlay. Messages that never reach it are not in the stream. We publish no completeness or speed figures.
  • Messages sent through TONNode's liteservers are not in the stream. Transactions you send with a TONNode liteserver key are not echoed to mempool subscribers.
  • Pending does not mean included. A checked message passed the node's broadcast checks, not the validator's execution. A substantial share is never accepted — in one 90-minute sample in October 2026, roughly one in five.
  • Early messages are unverified. They may be garbage or forgeries injected by any overlay peer; see Early (unverified) messages.
  • parsed is best effort. It is inferred from the body alone and can be wrong for contracts that imitate a wallet layout.
  • Duplicates happen. The server sends each cell_hash at most once per path within a de-duplication window of at least 120 s; rare repeats after that, and repeats after a reconnect, are possible.
  • Some messages are dropped on purpose: BoCs larger than 65,535 bytes, BoCs that are not external messages, and destinations that are not plain addr_std.

The stream's state and message rate appear on the status page.


Code examples

Each example connects with a server key from the TONNODE_MEMPOOL_KEY environment variable, handles hello → resume → subscribe, reconnects with backoff, and stops on a refused key. Reading the endpoint from MEMPOOL_URL (defaulting to production) is only there so the same file can be pointed elsewhere.

JavaScript (Node.js, server key)

Node.js 18 or newer with the ws package, which can set the Authorization header. It takes everything, early messages included, and prints a line per message.

JavaScript
// npm install ws
import WebSocket from "ws";

const URL = process.env.MEMPOOL_URL ?? "wss://mempool.tonnode.io/v1/stream";
const KEY = process.env.TONNODE_MEMPOOL_KEY; // tnmp_<key_id>_<secret>

let last = null; // { epoch, seq } of the last event handled, for resume
let backoff = 500;
let stopped = false; // the key was refused: reconnecting will not help

function connect() {
  const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${KEY}` } });

  // The server may refuse the upgrade itself with an HTTP status. terminate()
  // ends the attempt and still emits "close", which retries anything else.
  ws.on("unexpected-response", (req, res) => {
    if (res.statusCode === 401 || res.statusCode === 403) {
      console.error(`refused with HTTP ${res.statusCode}: check the key in the console`);
      stopped = true;
    }
    ws.terminate();
  });

  ws.on("message", (data) => {
    const m = JSON.parse(data.toString());
    switch (m.type) {
      case "hello":
        backoff = 500;
        console.log(`hello epoch=${m.epoch} head_seq=${m.head_seq} limits=${JSON.stringify(m.limits)}`);
        // resume is only valid before the first subscribe on a connection
        if (last) ws.send(JSON.stringify({ v: 1, type: "resume", epoch: last.epoch, last_seq: last.seq }));
        ws.send(JSON.stringify({ v: 1, type: "subscribe", filters: {} }));
        break;
      case "subscribed":
        console.log(`subscribed filters=${m.filters} include_unverified=${m.include_unverified} from_seq=${m.from_seq}`);
        break;
      case "msg": {
        last = { epoch: m.epoch, seq: m.seq };
        const out = m.parsed?.out?.[0];
        console.log(`${m.seq} ${m.path}${m.unverified ? " (unverified)" : ""} dest=${m.dest} hash=${m.cell_hash.slice(0, 16)}… ${out?.op ?? ""} ${out?.dex?.kind ?? ""}`);
        break;
      }
      case "gap":
        last = { epoch: m.epoch, seq: m.to_seq };
        console.warn(`gap ${m.reason} ${m.from_seq}..${m.to_seq}`);
        break;
      case "error":
        console.error(`error ${m.code}: ${m.message} (fatal=${m.fatal})`);
        break;
    }
  });

  ws.on("close", (code, reason) => {
    if (stopped) return;
    // 4401 / 4403: unknown, revoked, suspended or expired key. Retrying will not help.
    if (code === 4401 || code === 4403) {
      console.error(`closed ${code} ${reason}: check the key in the console`);
      return;
    }
    const wait = Math.min(backoff, 30_000) * (0.5 + Math.random());
    backoff = Math.min(backoff * 2, 30_000);
    console.warn(`closed ${code}, reconnecting in ${Math.round(wait)} ms`);
    setTimeout(connect, wait);
  });

  ws.on("error", (err) => console.error(`websocket error: ${err.message}`));
}

connect();

JavaScript (browser, ticket)

Two parts. Your backend holds the key and hands the page a ticket (Node.js 18 or newer, no dependencies):

JavaScript
// Your backend (Node 18+, no dependencies). It holds the key and hands the
// browser a one-time ticket; the key itself never reaches the page.
import { createServer } from "node:http";

const TICKET_URL = process.env.MEMPOOL_TICKET_URL ?? "https://mempool.tonnode.io/v1/ticket";
const KEY = process.env.TONNODE_MEMPOOL_KEY;

createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/mempool-ticket") {
    // Check your own user's session here before handing out a ticket.
    const r = await fetch(TICKET_URL, { method: "POST", headers: { Authorization: `Bearer ${KEY}` } });
    res.writeHead(r.status, { "content-type": "application/json" });
    res.end(await r.text()); // {"v":1,"ticket":"tnmt_…","expires_at":…,"subprotocols":["tnmp.v1","tnmp.ticket.tnmt_…"]}
    return;
  }
  res.writeHead(404).end();
}).listen(Number(process.env.PORT ?? 8080));

The page fetches a fresh ticket for every connection and opens the socket with the returned subprotocols. This one opts out of early messages:

JavaScript
const STREAM_URL = "wss://mempool.tonnode.io/v1/stream";

let last = null; // { epoch, seq } of the last event handled, for resume
let backoff = 500;

async function connect() {
  // A fresh ticket for every connection: it is valid for 30 s and one upgrade.
  const r = await fetch("/mempool-ticket", { method: "POST" });
  if (r.status === 401 || r.status === 403) return console.error("key refused", await r.text());
  if (!r.ok) throw new Error(`ticket: HTTP ${r.status}`);
  const { subprotocols } = await r.json();
  const ws = new WebSocket(STREAM_URL, subprotocols);

  ws.onmessage = (ev) => {
    const m = JSON.parse(ev.data);
    if (m.type === "hello") {
      backoff = 500;
      if (last) ws.send(JSON.stringify({ v: 1, type: "resume", epoch: last.epoch, last_seq: last.seq }));
      ws.send(JSON.stringify({ v: 1, type: "subscribe", filters: {}, include_unverified: false }));
    } else if (m.type === "msg") {
      last = { epoch: m.epoch, seq: m.seq };
      console.log(m.seq, m.path, m.dest, m.cell_hash);
    } else if (m.type === "gap") {
      last = { epoch: m.epoch, seq: m.to_seq };
      console.warn("gap", m.reason, m.from_seq, m.to_seq);
    } else if (m.type === "error") {
      console.warn("error", m.code, m.message);
    }
  };
  ws.onclose = (ev) => {
    if (ev.code === 4401 || ev.code === 4403) return console.error("key refused", ev.code, ev.reason);
    reconnect();
  };
}

function reconnect() {
  const wait = Math.min(backoff, 30_000) * (0.5 + Math.random());
  backoff = Math.min(backoff * 2, 30_000);
  setTimeout(() => connect().catch(reconnect), wait);
}

connect().catch(reconnect);

Python

Python 3.9 or newer with websockets 14 or newer (older versions name the header argument extra_headers). Checked messages only:

Python
# pip install "websockets>=14"
import asyncio
import json
import os
import random

import websockets

URL = os.environ.get("MEMPOOL_URL", "wss://mempool.tonnode.io/v1/stream")
KEY = os.environ["TONNODE_MEMPOOL_KEY"]  # tnmp_<key_id>_<secret>


async def main():
    last = None  # (epoch, seq) of the last event handled, for resume
    backoff = 0.5
    while True:
        try:
            async with websockets.connect(URL, additional_headers={"Authorization": f"Bearer {KEY}"}) as ws:
                async for raw in ws:
                    m = json.loads(raw)
                    kind = m["type"]
                    if kind == "hello":
                        backoff = 0.5
                        print("hello", m["epoch"], "head_seq", m["head_seq"], "limits", m["limits"])
                        if last:  # resume is only valid before the first subscribe
                            await ws.send(json.dumps({"v": 1, "type": "resume", "epoch": last[0], "last_seq": last[1]}))
                        await ws.send(json.dumps({"v": 1, "type": "subscribe", "filters": {}, "include_unverified": False}))
                    elif kind == "subscribed":
                        print("subscribed", m)
                    elif kind == "msg":
                        last = (m["epoch"], m["seq"])
                        outs = m.get("parsed", {}).get("out", [])
                        print(m["seq"], m["path"], m["dest"], m["cell_hash"][:16], [o.get("op") for o in outs])
                    elif kind == "gap":
                        last = (m["epoch"], m["to_seq"])
                        print("gap", m["reason"], m["from_seq"], m["to_seq"])
                    elif kind == "error":
                        print("error", m["code"], m.get("message"), "fatal" if m.get("fatal") else "")
        except websockets.ConnectionClosedError as e:
            if e.rcvd and e.rcvd.code in (4401, 4403):  # bad, revoked, suspended or expired key
                print("key refused:", e.rcvd.code, e.rcvd.reason)
                return
            print("connection closed:", e)
        except websockets.InvalidStatus as e:  # the upgrade itself was refused
            if e.response.status_code in (401, 403):
                print("key refused: HTTP", e.response.status_code)
                return
            print("handshake failed:", e)
        except OSError as e:
            print("connect failed:", e)
        # also reached after a normal close (1000, or 1001 when the server restarts)
        await asyncio.sleep(min(backoff, 30) * random.uniform(0.5, 1.5))
        backoff = min(backoff * 2, 30)


asyncio.run(main())

Go

Go 1.21 or newer with gorilla/websocket:

Go
// go get github.com/gorilla/websocket
package main

import (
	"errors"
	"log"
	"math/rand"
	"net/http"
	"os"
	"time"

	"github.com/gorilla/websocket"
)

type Out struct {
	Dest  string `json:"dest"`
	Value string `json:"value"`
	Op    string `json:"op"`
}

type Event struct {
	Type       string `json:"type"`
	Epoch      string `json:"epoch"`
	Seq        int64  `json:"seq"`
	HeadSeq    int64  `json:"head_seq"`
	FromSeq    int64  `json:"from_seq"`
	ToSeq      int64  `json:"to_seq"`
	Reason     string `json:"reason"`
	Code       string `json:"code"`
	Message    string `json:"message"`
	Dest       string `json:"dest"`
	CellHash   string `json:"cell_hash"`
	Path       string `json:"path"`
	Unverified bool   `json:"unverified"`
	Boc        string `json:"boc"`
	Parsed     *struct {
		Wallet string `json:"wallet"`
		Out    []Out  `json:"out"`
	} `json:"parsed"`
}

func main() {
	url := os.Getenv("MEMPOOL_URL")
	if url == "" {
		url = "wss://mempool.tonnode.io/v1/stream"
	}
	header := http.Header{"Authorization": {"Bearer " + os.Getenv("TONNODE_MEMPOOL_KEY")}}

	var epoch string // last event handled, for resume
	var seq int64
	backoff := 500 * time.Millisecond
	for {
		conn, resp, err := websocket.DefaultDialer.Dial(url, header)
		if err != nil {
			if resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 403) {
				log.Fatalf("key refused: HTTP %d", resp.StatusCode)
			}
			log.Printf("dial: %v", err)
		} else {
			if fatal := read(conn, &epoch, &seq, &backoff); fatal {
				return
			}
		}
		time.Sleep(time.Duration(float64(backoff) * (0.5 + rand.Float64())))
		backoff = min(backoff*2, 30*time.Second)
	}
}

// read handles one connection until it closes; true means do not reconnect.
func read(conn *websocket.Conn, epoch *string, seq *int64, backoff *time.Duration) bool {
	defer conn.Close()
	for {
		var ev Event
		if err := conn.ReadJSON(&ev); err != nil {
			var ce *websocket.CloseError
			if errors.As(err, &ce) && (ce.Code == 4401 || ce.Code == 4403) {
				log.Printf("key refused: %d %s", ce.Code, ce.Text)
				return true
			}
			log.Printf("read: %v", err)
			return false
		}
		switch ev.Type {
		case "hello":
			*backoff = 500 * time.Millisecond
			log.Printf("hello epoch=%s head_seq=%d", ev.Epoch, ev.HeadSeq)
			if *epoch != "" { // resume is only valid before the first subscribe
				conn.WriteJSON(map[string]any{"v": 1, "type": "resume", "epoch": *epoch, "last_seq": *seq})
			}
			conn.WriteJSON(map[string]any{"v": 1, "type": "subscribe", "filters": map[string]any{}})
		case "msg":
			*epoch, *seq = ev.Epoch, ev.Seq
			op := ""
			if ev.Parsed != nil && len(ev.Parsed.Out) > 0 {
				op = ev.Parsed.Out[0].Op
			}
			log.Printf("%d %s unverified=%v dest=%s hash=%s op=%s", ev.Seq, ev.Path, ev.Unverified, ev.Dest, ev.CellHash[:16], op)
		case "gap":
			*epoch, *seq = ev.Epoch, ev.ToSeq
			log.Printf("gap %s %d..%d", ev.Reason, ev.FromSeq, ev.ToSeq)
		case "error":
			log.Printf("error %s: %s", ev.Code, ev.Message)
		}
	}
}

To narrow any of them down, put filters into the subscribe, for example "filters":{"op":["0x0f8a7ea5"]} for jetton transfers or "filters":{"dest":["UQ…your wallet…"]} for one wallet.


Prompts for AI agents

Paste one of these into a coding agent to get a working consumer. Each one carries the protocol facts the agent needs, so it does not have to guess; it can also read this page at https://tonnode.io/en/docs/mempool.

A resilient stream consumer

Text
Build a long-running service in <language> that consumes the TONNode TON mempool stream.

Protocol (authoritative, do not invent anything else):
- WebSocket wss://mempool.tonnode.io/v1/stream. Authenticate with the HTTP header
  "Authorization: Bearer <key>"; read the key from the env var TONNODE_MEMPOOL_KEY and never log it.
- Every message is one JSON object with "v":1 and a "type". The server sends "hello" first
  (fields: epoch, head_seq, oldest_seq, key_id, limits). Nothing is delivered until the client sends
  {"v":1,"type":"subscribe","filters":{}}; the server answers "subscribed".
- Events: "msg" (fields: epoch, seq, ts_node_us, ts_gw_us, boc (base64), size, cell_hash, norm_hash,
  sha256, dest, unverified, path "early"|"checked", optional parsed {wallet, valid_until, seqno,
  out:[{dest, value, mode, op, jetton?, dex?}]}) and "gap" (epoch, from_seq, to_seq, reason).
  Ignore unknown fields and types.
- Remember the epoch and the last seq handled (a msg's seq or a gap's to_seq). After a reconnect,
  send {"v":1,"type":"resume","epoch":E,"last_seq":S} BEFORE the first subscribe.
- Reconnect with exponential backoff: start 0.5 s, cap 30 s, random jitter. Close code 1001 means a
  server restart: reconnect and resume. Close codes 4401 and 4403 mean the key is wrong, revoked,
  suspended or expired: stop and report, do not retry in a loop. 4429 means all connections of the
  key are in use: back off.
- "error" messages have code, message, fatal. Non-fatal errors keep the connection.
- Messages with "unverified": true are written before the node's signature check and may be garbage
  or forgeries. Keep them out of any automated decision unless a "checked" message with the same
  cell_hash arrives or the chain confirms it.
- The same cell_hash can arrive more than once (early then checked, or again after a reconnect):
  de-duplicate by (cell_hash, path) with a TTL of a few minutes.
- Coverage is partial and pending messages may never be included; never treat absence from the
  stream as proof that something did not happen.

Deliverables: the service, structured logs (seq, path, dest, cell_hash, op), a counter of gaps by
reason, graceful shutdown, and a short README explaining how to run it.

Watch a set of wallets

Text
Write a <language> program that watches TON wallets in the TONNode mempool stream and prints every
pending outgoing transfer from them.

- Connect to wss://mempool.tonnode.io/v1/stream with "Authorization: Bearer $TONNODE_MEMPOOL_KEY".
- Wait for {"type":"hello"}, then send
  {"v":1,"type":"subscribe","filters":{"dest":[<addresses>]},"include_unverified":false}.
  Addresses may be raw "0:<64 hex>" or 48-character user-friendly strings; the server normalises them.
  "dest" matches the wallet that sends the external message. A subscription may hold at most
  10,000 entries; on {"type":"error","code":"too_many_filters"} or "bad_request" the previous
  subscription stays in force.
- For each {"type":"msg"}: print dest, cell_hash, and for each parsed.out[] item its dest, value
  (nanotons, a decimal string: use a big-integer type) and, if present, jetton.amount and jetton.to.
  parsed may be absent; then print only dest and cell_hash.
- Track epoch and the last seq (msg.seq or gap.to_seq); on reconnect send
  {"v":1,"type":"resume","epoch":...,"last_seq":...} before subscribing again. Backoff 0.5 s to 30 s
  with jitter; stop on close codes 4401/4403.
- A pending message is not a confirmed transaction: label output "pending" and never mark anything
  as paid from the stream alone.

A browser dashboard with tickets

Text
Build a small web page plus backend that shows the live TONNode TON mempool stream in a browser.

- Backend (<framework>): an authenticated route POST /mempool-ticket that calls
  POST https://mempool.tonnode.io/v1/ticket with "Authorization: Bearer $TONNODE_MEMPOOL_KEY" and
  returns the JSON unchanged: {"v":1,"ticket":"tnmt_…","expires_at":<unix s>,"subprotocols":[…]}.
  The key must never reach the browser.
- Page: fetch a fresh ticket for every connection (a ticket is valid 30 s and for one connection),
  then new WebSocket("wss://mempool.tonnode.io/v1/stream", subprotocols). On "hello", send
  {"v":1,"type":"subscribe","filters":{},"include_unverified":false} (or resume first after a
  reconnect, using the last epoch and seq).
- Render a rolling table of the last 200 "msg" events: time from ts_gw_us (microseconds), dest,
  short cell_hash, parsed.wallet, the first parsed.out[] op and dex.kind. Show "gap" events as a
  separator row with the reason.
- Reconnect with backoff (0.5 s to 30 s, jitter); on close code 4401/4403 show
  "key refused" and stop.
- Label the view "pending messages — not confirmed, coverage partial".