All posts
9 min read

tonapi.io Rate Limits: How to Fix the 429 Error

tonapi.io returns HTTP 429 when you exceed its rate limit. How to fix error 429, why "228" is just a meme, and how to get your own key via TONNode's MCP.

tonapi429 errorrate limitTON APIMCPTONNode

tonapi 429: it's 8:30 pm, production is on fire, and the logs are a wall of red

Your TON bot just got featured somewhere, users came flooding in, and the app suddenly started returning empty balances. You open the logs — hundreds of lines of:

HTTP 429 Too Many Requests

Sound familiar? You're hitting tonapi.io for balances and transaction history, everything worked fine with ten users, and at a thousand your anonymous access slammed into the ceiling. It's a classic: while traffic is small, a public API feels free and infinite. The moment load grows, it turns into a bottleneck and you start catching tonapi 429 by the dozen. It's not a bug in your code and not "the node went down" — it's the public API's rate limit, and it has a predictable cure.

Let's break down why 429 happens, why the infamous "228" has nothing to do with it, which fixes actually help, and how to move off the shared anonymous pool onto a personal limit for reading TON — via the TONNode MCP server.

Why tonapi.io returns 429 Too Many Requests

tonapi.io is a public HTTP API for TON data. Like any public service, it has a tonapi rate limit — a cap on request frequency so a single client can't eat all the capacity.

When you make requests without a key, you're sharing one anonymous pool with every other anonymous client on the planet. In practice that's a limit of roughly ~1 request per second. For a single script, that's tolerable. But as soon as you have a parallel worker that fetches the balance, then the jetton balance, then the history for every wallet, you instantly blow past the per-second limit. It hurts the most on a cold start, when you need to index a lot of addresses at once.

The server responds like this:

HTTP/1.1 429 Too Many Requests
Retry-After: 1

An important point: 429 is a standard HTTP code from the spec, not some proprietary tonapi code. Plenty of services return it when you exceed a request rate — GitHub, Stripe, Cloudflare, any rate-limited service. toncenter and most RPC providers use the exact same mechanism. Which means the standard fixes apply here too — and those are what we'll cover below.

"Error 228" is not an API code — it's a community meme (the real code is 429)

If you've googled the problem on Russian-speaking forums, you've almost certainly run into "error 228". Let's set the record straight, because it genuinely confuses newcomers.

"228" is not an official error code of tonapi or toncenter. It's a meme number that has lived in the TON community for ages and keeps popping up in chats and jokes. No HTTP status 228 is ever going to come back at you for exceeding a limit — that status code simply doesn't exist in HTTP.

The real code you'll see in your logs and response headers is 429. When someone in a chat says they "caught a 228 from tonapi", what they actually caught was a 429 (or a plain timeout), and "228" is just a figure of speech. Grep your logs for 429, not "228" — that's how you find the actual cause. You can verify it in one line:

curl -s -o /dev/null -w "%{http_code}\n" https://tonapi.io/v2/blockchain/masterchain-head
# under load without a key you'll see: 429

Quick fixes: retries, backoff, caching, and your own key

Since 429 is a standard problem, it has a standard set of fixes. We'll work from the simplest up to the one that actually matters.

1. Exponential backoff that respects Retry-After

Don't hammer the endpoint in a tight loop after the first rejection — you'll only make things worse. On a 429, the server often sends a Retry-After header telling you how many seconds to wait. Respect it, and if it's missing, grow the pause exponentially.

async function fetchWithBackoff(url, opts = {}, maxRetries = 5) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(url, opts);
    if (res.status !== 429) return res;

    // Respect Retry-After, otherwise grow the pause exponentially
    const retryAfter = Number(res.headers.get('retry-after'));
    const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : Math.min(1000 * 2 ** attempt, 30_000); // 1s, 2s, 4s… capped at 30s

    await new Promise(r => setTimeout(r, waitMs));
  }
  throw new Error('429 persisted: retry limit exceeded');
}

2. Cap the number of concurrent requests

Often the 429 comes not from total volume but from firing 50 requests in a fan-out via Promise.all. Put a semaphore with a concurrency limit (1–4) in front of it — the bursts smooth out and stop punching through the per-second limit.

3. Cache immutable data

Jetton metadata (name, symbol, decimals), address conversion results, old transactions — this data never changes. Put it in a local cache with a sensible TTL and stop asking the API again. Caching jetton decimals alone removes a noticeable share of the calls.

4. The main fix — your own key instead of anonymous access

The first three points are painkillers. The actual cure is to move off the anonymous pool onto your own key. A personal key raises your limit by orders of magnitude compared to ~1 req/s for anonymous access; you stop competing with the entire internet, and 429 simply disappears from normal operation. The same fix for the other big provider is covered in the toncenter 429 fix — the logic is identical.

What to do when tonapi limits still aren't enough

Say you've done everything right: backoff, cache, a queue. But the app keeps growing, and even with a key you run into either your plan's ceiling or the limits of the "HTTP layer on top of a node" model itself. At that point, people usually consider two paths.

The "run my own liteservers" path. It's tempting to "just grab a public liteserver from the TON global config and talk ADNL directly". An honest caveat: this is not a silver bullet. Public liteservers from the global config are also shared and rate-limited — under load they regularly answer not ready or drop with ADNL timeouts, and they don't keep deep transaction history. not ready is a classic pain in its own right — we break it down in how to fix "liteserver not ready". In other words, simply "switching to the public config" doesn't solve the limits problem — sometimes it makes it worse.

The "change the provider/interface" path. The problem isn't tonapi.io specifically — it's that you're sitting on a shared resource. A survey of HTTP API alternatives lives in tonapi alternatives in 2026. And if you're building an AI agent, it makes sense not to hand-wrap REST at all, but to plug TON in as a set of tools via MCP — then reading the blockchain becomes a tool call, not a raw HTTP request you have to retry.

TONNode MCP: a personal limit instead of the shared anonymous pool

TONNode (website: tonnode.io) is a hosted MCP server for TON. MCP (Model Context Protocol) is the standard AI agents (Claude, Cursor, ChatGPT/Codex, and any MCP client) use to call external tools. Instead of teaching your agent to hit https://tonapi.io/v2/... and handle 429s, you give it a set of named tools for reading TON.

The key point: the @tonnode/mcp package talks TON's native ADNL protocol, with no HTTP layers in between. It's open source (MIT), published on npm and GitHub (tonnode/mcp). This is not yet another REST wrapper around tonapi — it's a direct conversation with the network.

The typical requests you used to make to tonapi map one-to-one onto the read tools:

  • get_balance — GRAM balance of an address.
  • get_jetton_balance — balance of USDT or any jetton; the jetton wallet is computed on-chain, so you don't have to derive its address yourself. How this replaces a chain of several requests is covered in USDT balance on TON in one call.
  • get_account_state — account status, flags, last transaction.
  • get_transactions — transaction history.
  • run_get_method — any read-only get method of a contract.
  • get_masterchain_info — masterchain head (the latest block).
  • get_jetton_info — jetton metadata: name, symbol, supply, and decimals (6 for USDT, 9 for most jettons; without them you'll misconvert raw units).
  • parse_address — offline conversion and validation of EQ/UQ/raw addresses, no network access at all.

A quick note on naming: GRAM is Toncoin, renamed in June 2026. The network is still called TON; only the coin's name changed. get_balance balances are in GRAM.

Here's a prompt the agent will fulfill through tools rather than hand-rolled HTTP:

Check the GRAM balance and the USDT balance of the address
UQBvW8Z5huBkMJYdnfAEM5JqTNkuWX3diqYENkWsIL0XF_wm
and show the last 5 transactions of this wallet.

The agent will call get_balance on its own, then get_jetton_balance (computing the jetton wallet on-chain), then get_transactions — without a single HTTP request written by you, without Retry-After, and without manual backoff in your code.

Setting it up in a minute: local for development, or a hosted key for production

There are two ways, and both are honest. Just don't mix them up: local npx without a key is great for development but runs on TON's public config; the personal limit that kills the 429 pain under load comes specifically from a hosted key.

Option A — local, free, no key (for development)

The full set of read tools spins up with a single npx command — no sign-up, no card. Add this to your MCP client config:

{
  "mcpServers": {
    "ton": {
      "command": "npx",
      "args": ["-y", "@tonnode/mcp"]
    }
  }
}

A perfect start for local development and testing: the full read set, zero configuration, open source under the hood. But an honest caveat: this mode runs over TON's public config, which means it's subject to the same shared constraints as any public liteservers (not ready, ADNL timeouts under load). For production traffic it's no substitute for a personal limit — for that, go to option B.

Option B — hosted endpoint with your own key (for production)

When you need guaranteed throughput and a personal limit under production load, connect the hosted endpoint with your own Bearer key:

{
  "mcpServers": {
    "ton": {
      "type": "http",
      "url": "https://mcp.tonnode.io/mcp",
      "headers": { "Authorization": "Bearer tn_live_…" }
    }
  }
}

Pricing

Every plan includes all 16 TONNode tools — you only pay for throughput:

  • Hobby — free forever, 60 requests/min. You get a key right after signing in, no card required.
  • Pro — $29/mo, 300 requests/min.
  • Scale — $199/mo, 1200 requests/min.

The difference from anonymous tonapi is obvious: there you share ~1 req/s with the entire internet, here you get a personal ceiling. Even on the free hosted Hobby key that's 60 requests per minute just for you — no wrestling with backoff logic, no random 429s. And it's not the same free path as local npx without a key: the hosted Hobby key has its own limit, not the shared public pool.

Bottom line

A 429 from tonapi.io is not a bug and not the mythical "228" — it's an honest signal that you're sitting on the shared anonymous pool and have hit its ceiling. Retries with backoff, respecting Retry-After, capping concurrent requests, and caching relieve the acute pain. But the problem truly goes away only when you get capacity of your own — and if you're building on AI agents, it's also just more convenient to do it via MCP, where reading TON goes over the native protocol instead of an HTTP layer.

Grab a free hosted Hobby key (60 req/min, no card) and stop catching 429stonnode.io/dashboard?plan=hobby

Need headroom for production load — compare the Pro and Scale plans: tonnode.io/pricing.

Give your agent TON access

16 MCP tools: reads, non-custodial swaps, cross-chain and wallets. Free tier — 60 req/min, no card required.