DEV Community

Cover image for I built a TON explorer where every page is a public API call
apiTON
apiTON

Posted on

I built a TON explorer where every page is a public API call

A surprising part of Telegram lives on a blockchain. Collectible @usernames, anonymous +888 phone numbers and gifts are NFTs on The Open Network (TON), and so are .ton domains. They change hands every minute — yet simple questions like "which 4-letter usernames are on auction right now?" or "which .ton domains expired this month?" usually mean clicking through marketplaces one item at a time.

apiton.org is my answer to that: an explorer that lists all of it with a status (not listed, for sale, on auction, expired), a price and a history, plus a directory of live TON Sites with screenshots. As of 25 October 2026 it tracks:

Kind Total For sale On auction
Telegram usernames 674,469 44,460 16,871
Anonymous +888 numbers 136,566 440 84
.ton domains 199,832 28,734 120

…plus 28,623 expired .ton domains, 64,596 Telegram gifts and 382 live TON Sites.

But the part I want to talk about is how it is built: the website has no database access at all. It is a separate Go program that renders every page from the same public API anyone can call. The Telegram bot @apitonBot works the same way. When the site needs something new, it becomes an API method first.

The rule: the site is just another API client

apiton.org (Go, html/template) ─┐
@apitonBot (Go)                 ├──► POST https://app.apiton.org/v2.<Service>/<Method>
your script, curl, an AI agent ─┘          │
                                           ├── Postgres catalog: usernames, numbers, domains, gifts, TON Sites
                                           └── TON liteservers: live on-chain state
Enter fullscreen mode Exit fullscreen mode

Why bother with this discipline on a one-person project?

  1. The API can't rot. If a field breaks, my own pages break first, and I see it before anyone else does.
  2. Every site feature is an API feature. Expired-domain lists, the sales feed, gift collection floors, TON Site status — all of them are public methods, because the site has no other way to get them.
  3. A small blast radius. The site imports no backend package. It can be cached, restarted or rewritten without touching the data layer.

Try it: no key, just curl

The API speaks Connect, the HTTP-friendly sibling of gRPC: every call is a POST with a JSON body to https://app.apiton.org/v2.<Service>/<Method>. gRPC and gRPC-Web clients generated from the same protos work too. Anonymous calls are allowed — 1 request per second and 10,000 a month per IP.

How big is the catalog?

curl -s -X POST https://app.apiton.org/v2.Catalog/Summary \
  -H 'Content-Type: application/json' -d '{}'
Enter fullscreen mode Exit fullscreen mode
{
  "kinds": [
    {"kind": "username", "total": "674469",
     "by_status": {"auction": "16871", "not_listed": "613138", "sale": "44460"}},
    ...
  ],
  "live_sites": "382"
}
Enter fullscreen mode Exit fullscreen mode

These are the numbers from the table above, and the ones on the apiton.org home page.

The cheapest .ton domains for sale

curl -s -X POST https://app.apiton.org/v2.Catalog/List \
  -H 'Content-Type: application/json' \
  -d '{"kind":"domain","statuses":["sale"],"sort":"price_asc","limit":2}'
Enter fullscreen mode Exit fullscreen mode
{
  "items": [
    {
      "kind": "domain",
      "name": "prommeteeev",
      "nft_address": "UQBt9bwjG5fTpT166sh76zjM0zKQ5rvhr44_vQcE0vPK8SyN",
      "owner_address": "UQBixgxoacPjMpiqOjr3t7FDnFsYP_Pbxg8VgK2mxckIQspb",
      "sale_contract": "UQCfowehxX68MXv1HGmVMdrBt2HNQdyxIWI7M41HpKiQYiuA",
      "status": "sale",
      "price": 0.468,
      "price_source": "chain",
      "expires_at": "2027-10-19T16:45:46Z",
      "name_len": 11
    },
    ...
  ],
  "total": "28734"
}
Enter fullscreen mode Exit fullscreen mode

price is in the network's own coin (shown as 💎 on the site), and price_source says whether it came from a sale contract on chain or from a Fragment listing. List filters by kind (username, number, domain, gift), statuses, name_len, digits_only, prefix and an expiry window, and sorts by name, price_asc, price_desc, expires_asc or changed. Two more recipes:

# 4-letter usernames on auction
curl -s -X POST https://app.apiton.org/v2.Catalog/List \
  -H 'Content-Type: application/json' \
  -d '{"kind":"username","statuses":["auction"],"name_len":4}'

# expired .ton domains, most recently changed first
curl -s -X POST https://app.apiton.org/v2.Catalog/List \
  -H 'Content-Type: application/json' \
  -d '{"kind":"domain","statuses":["expired"],"sort":"changed"}'
Enter fullscreen mode Exit fullscreen mode

The full list of fields is in this guide on the apiton blog and in the API reference.

What just sold

curl -s -X POST https://app.apiton.org/v2.Catalog/Events \
  -H 'Content-Type: application/json' \
  -d '{"types":["sold"],"limit":3}'
Enter fullscreen mode Exit fullscreen mode

Pass a kind and a name instead and you get the history of one item: listed, delisted, re-priced, sold, bids, expiry, renewal.

Live state of one item

The catalog is refreshed in the background. For the state of a single item right now, the v2.Nft methods read the chain:

curl -s -X POST https://app.apiton.org/v2.Nft/Domain \
  -H 'Content-Type: application/json' \
  -d '{"domain":"apiton.ton"}'
Enter fullscreen mode Exit fullscreen mode
{
  "address": "UQABUZhK7peJbJzDxChcbitG9oJUgxWmk1TqZwU76aMEKALt",
  "owner_address": "UQAyJ9vVRMGVKg9-6R-KO3alF2ITcccDBAhAi1Qj4HCpVP5Q",
  "time_expire": "2027-10-08T16:16:31Z",
  "site": {
    "kind": "adnl",
    "status": "live",
    "title": "apiton — TON explorer: names, gifts and TON Sites",
    "uptime_30d": 1,
    "live_since": "2026-10-07T16:18:19.961661Z",
    "gateway_url": "https://site.apiton.org/apiton.ton/"
  }
}
Enter fullscreen mode Exit fullscreen mode

Yes, the explorer is a TON Site itself — more on that below. v2.Nft/Username, Number and Gift work the same way, and v2.Address covers wallets: balance, jettons, NFTs and transactions.

Three JSON gotchas

The responses are proto3 JSON, which surprises people coming from hand-written REST APIs:

  • int64 arrives as a string: "total": "28734". Parse it; don't compare it to a number.
  • Zero values are omitted. An empty list is {}, not {"items": [], "total": "0"}.
  • Errors use the Connect envelope with a proper HTTP status:
curl -s -X POST https://app.apiton.org/v2.Catalog/List \
  -H 'Content-Type: application/json' -d '{"kind":"planet"}'
# HTTP 400
# {"code":"invalid_argument","message":"unknown kind \"planet\" (username | number | domain | gift)"}
Enter fullscreen mode Exit fullscreen mode

Too many requests is resource_exhausted (HTTP 429). unavailable means the TON liteservers are lagging; retry after a second or two. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.

What public liteservers taught me

Live reads go to the public TON liteservers. They are free, and they are moody. Between 23 and 25 October, 54–83 % of my liteserver requests were answered with error 651 — "the node is behind". That rate did not change when I tripled my own background load, so it was not me.

What made the API usable anyway:

  1. A pool with failover. Keep four connections open; a 429 or a 651 is retried on another server, not on the same one.
  2. Don't read the newest block. Reads run on a masterchain block about 10 seconds behind the head, which a node that is slightly behind can still serve. If six servers refuse, the answer comes from an older block (at most 45 seconds old). The one exception: before a send, a wallet's own state is read on the newest block only, or the send fails before anything is signed.
  3. One chain read per question. A 45-second live cache sits in front of the item methods. Identical concurrent requests share a single chain read that is detached from the caller's context, so a client hanging up doesn't cancel it for everyone else. Errors are never cached.
  4. The pool's answer is final. No retry loops stacked on top of it — that is how a bad minute becomes a retry storm. Clients get a clear unavailable instead, and the bot retries once after 1.5 seconds.

The other lesson was quieter and more expensive. For months, every sale and auction price read silently returned nothing, because of a typo in the default URL of the archive endpoint those reads went through. "No price" looked like a valid answer, so nothing alerted. The fix was a rule rather than a patch: state and get-method reads go to liteservers only, and the archive is used for old transactions only.

.ton domains can't be listed from the chain

You'd think the 199,832 domains came from iterating the DNS collection. They can't. The root .ton collection indexes each item by hash(name) rather than a counter, so there is no 0..N to walk. A domain also lapses silently: it is valid for a year after its last renewal (last_fill_up_time), and no transaction marks the moment it expires.

So the catalog learns about domains as they show up on chain — transfers, sales, DNS changes — and then re-reads each one on a schedule to compute its expiry. A small trap on the way: get_last_fill_up_time returning 0 means the item was never initialized, not that it expired long ago.

TON Sites: a directory of the "other" web

A .ton domain can point to a website through its DNS site record — an ADNL address or a TON Storage bag. apiton reads that record for every domain, probes each site over RLDP-HTTP or TON Storage, tells parked pages from live ones, ranks them and takes screenshots with a headless Chrome whose only way out is a .ton-only proxy. The result is apiton.org/sites/: 382 live sites today. Sites with clearly illegal content are listed by name with a reason, and never opened or previewed.

apiton.org is one of them. apiton.ton is served by a small Go program that speaks ADNL and RLDP-HTTP and reverse-proxies the regular website, GET and HEAD only. Without a TON proxy, you can open it through the gateway: site.apiton.org/apiton.ton/.

The bot and the limits

@apitonBot is the second client of the same API. Send it a wallet address, an @username, a +888 number, a name.ton or a gift link and you get a card. You can also watch items and wallets (listed, sold, new bid, incoming transaction), set price alerts, see a wallet's portfolio, and get an API key with /get_api. It answers in Russian for CIS languages and in English for everyone else.

With a key (Authorization: Bearer <tg_id>:<secret>) you get your own quota instead of sharing one with your IP:

  • free — 1 request/s, 10,000 a month;
  • builder — 10 requests/s, 300,000 a month;
  • pro — 100 requests/s, 3,000,000 a month.

Upgrades are paid in the bot with Telegram Stars or TON. The key, together with a wallet token, also unlocks custodial wallets, sends and jetton/NFT minting.

For AI assistants: llms.txt

apiton.org/llms.txt is a Markdown map of the site, the API and the bot, written for LLMs. Point an assistant or an agent at it and it can answer "what's the cheapest 5-letter username on sale?" with a real API call instead of a guess.

The stack, briefly

Go everywhere: connect-go over h2c for the API, Postgres through Bun, Redis for caches and quotas, tongo for TON. The site is server-rendered html/template with no JavaScript framework. React is used only for the bot's Mini App.

Try it

What would you build on top of it, and which method is missing? Tell me in the comments, or send /feedback to the bot.

Top comments (1)

Collapse
 
apiton profile image
apiTON •

Russian version / Русская версия статьи: telegra.ph/YA-sdelal-TON-ehksplore...