How to Reliably Detect an Incoming USDT Payment on TON
Detect USDT payments on TON: parsing jetton transfer_notification via get_transactions, decimals from get_jetton_info, amount matching and idempotency.
Anyone who has ever built payment acceptance on TON has run into the same trap: the payment arrives, but the backend "doesn't see it." The customer sent 50 USDT, the wallet shows the incoming transfer, and the invoice is still stuck in "pending." Ten minutes later — an angry support ticket. An hour after that you discover you credited the payment twice, because the poller fired on a retry. Reliably detecting an incoming USDT payment on TON is not "check the balance once a minute" — it's parsing specific internal jetton messages with idempotency and forgery protection. Let's walk through how to do it right, step by step, using real tools from the TONNode MCP server that you can call locally for free.
Why detecting a USDT payment on TON is not about the wallet balance
The naive approach is to read the wallet balance periodically and, if it went up, consider the payment received. That's like a cash register that can only look at the total in the drawer: +10 USDT came in — but from whom, for which invoice, for an old order or a new one? And if two customers each paid 10 at the same time, all you see is a single +20.
On TON this approach falls apart for several reasons at once:
- USDT is not the native coin — it's a jetton (the TEP-74 standard). Funds don't arrive at your main wallet directly; they land on its jetton wallet — a separate contract bound to a specific jetton master contract. Your main wallet's GRAM balance doesn't change at all.
- A balance delta doesn't tell you who paid, or for what. A balance carries no link to an invoice.
- Races and aggregation. Several payments arrive between two polls — you see the combined delta, not individual events.
- Double credits. One polling retry or worker restart, and a single payment is counted twice.
- Decimals. USDT has 6, not the 9 that most jettons use. Get the divisor wrong and the customer's credit lands 1000x off.
The right way is to work not with the balance but with the transaction stream of the recipient's jetton wallet, parsing each transfer event individually. All the reads are available locally for free:
{ "mcpServers": { "ton": { "command": "npx", "args": ["-y", "@tonnode/mcp"] } } }
What an incoming USDT payment looks like inside: the jetton internal_transfer (op 0x178d4519)
When someone sends you USDT, a chain of events happens:
- The sender's jetton wallet deducts the amount and sends your jetton wallet an internal
internal_transfermessage (op0x178d4519). - Your jetton wallet credits the funds and — only if the sender attached
forward_ton_amount > 0— additionally sends your main wallet an internaltransfer_notificationmessage (op0x7362d09c).
This is the crucial fork in the road. transfer_notification is a convenient "you've received a jetton" ping, but it goes to the main owner wallet and only when forward_ton_amount > 0. If the sender set it to zero, the jetton wallet still credits the funds — but the owner gets no notification.
The history of the jetton wallet itself, however, always contains the credit — as an incoming internal_transfer, regardless of forward_ton_amount. That's why reliable detection is built on polling the recipient's jetton wallet history and parsing the internal_transfer specifically. Its structure per TEP-74:
internal_transfer#178d4519
query_id: uint64
amount: (VarUInteger 16) // raw jetton units
from: MsgAddress // the PAYER's owner address (the human)
response_address: MsgAddress
forward_ton_amount: (VarUInteger 16)
forward_payload: (Either Cell ^Cell) // comment/memo
Two important points:
- The
fromfield is the address of the actual payer (the human owner), not the address of their jetton wallet. That's the address to log as the sender. Note that the message-level address (tx.in_msg.source) here is the payer's jetton wallet, while the human payer lives in thefromfield of the message body. - The
amountfield is in raw units — we'll get back to the conversion below.
The key architectural takeaway
Reliable detection is built not on waiting for a notification in the main wallet, but on polling the history of the recipient's jetton wallet itself. The credit is always visible there, regardless of forward_ton_amount. If you additionally listen to the main wallet, that's where you catch transfer_notification (op 0x7362d09c, with the sender in the sender field) — but that's just a bonus for the forward_ton_amount > 0 case, not your single source of truth.
Reading the history via get_transactions and parsing internal_transfer
First you need the recipient's jetton wallet address. Don't derive it offline and don't trust the ticker — ask the USDT master contract itself: calling get_wallet_address (via run_get_method) on the USDT master deterministically returns the address of your USDT jetton wallet, bound to the genuine master. Then poll that jetton wallet's transactions via get_transactions.
The prompt to give your agent inside the polling loop:
Call run_get_method on the USDT master
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs with the method
get_wallet_address and the owner address UQ...myservice as the argument.
Return our USDT jetton wallet address. Then use get_transactions
to read that jetton wallet's latest transactions. For every incoming
internal_transfer (op 0x178d4519) return query_id, amount (raw),
the from field (payer address), the text comment from
forward_payload, plus the transaction lt and hash.
Pseudocode for processing a single transaction:
for (const tx of txs) {
const body = tx.in_msg?.decoded; // decoded body of the incoming message
if (body?.op !== 0x178d4519) continue; // not an internal_transfer — skip
const rawAmount = BigInt(body.amount); // raw units, NOT human-readable
const payer = body.from; // the payer's owner address
const memo = parseComment(body.forward_payload);
const key = `${tx.lt}:${tx.hash}`; // identifier for dedup
// …matching and crediting below
}
It's also worth checking the main account's status once via get_account_state (is it active, when was the last transaction) — that helps you tell whether the poller is alive and whether it has fallen behind.
decimals decide everything: get_jetton_info and the raw-amount conversion (USDT = 6, not 9)
The amount field in a transfer is in the jetton's raw units — an integer with no decimal point. To turn it into a human-readable amount you need the decimals. And this is where most integrations break.
Most jettons have decimals = 9. USDT (Tether) on TON has decimals = 6. Which means:
1 USDT = 1 000 000 raw (10^6, not 10^9)
Divide a raw USDT amount by 10^9 out of habit, and a customer's 50 USDT payment turns into 0.05 on your side — 1000x less. An error in the other direction just as easily credits 1000x more. Don't hardcode the divisor — take decimals from the on-chain metadata via get_jetton_info:
const info = await getJettonInfo(USDT_MASTER); // name, symbol, decimals, supply
const decimals = info.decimals; // for USDT = 6
const human = Number(rawAmount) / 10 ** decimals; // 50000000 → 50.0
Cache decimals keyed by the master address, not by the ticker: tickers can be forged; decimals must be tied to a specific contract. The rule is simple: decimals always come from get_jetton_info, and the amount stays in BigInt/raw until the moment you display it.
Verifying origin, amount and comment — and cutting off fake jettons
Now the part that matters most for security: the checks you must run before crediting anything.
1. The master contract — only the genuine USDT. Anyone can mint a jetton named "USDT" with the symbol "USD₮" and send you a transfer of 1000 "USDT." If you match payments by ticker, you'll be scammed within five minutes. The only reliable way to bind to real Tether is to work with the jetton wallet that the genuine USDT master contract computed for your owner address:
Tether USD₮ master: EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs
That's exactly why we take the jetton wallet address from the on-chain get_wallet_address call on this master (see above), and poll history only on this — and no other — contract. Any internal_transfer credited to it is guaranteed to come from a legitimate sibling jetton wallet of the same master: a fake "USDT" lives on wallets of a different master and simply never reaches your address. So with this architecture there's nothing to compare tx.in_msg.source against — the protection is baked into the observation point you picked. And note: parse_address is only an offline EQ/UQ/raw format converter; it does not compute jetton wallet addresses — for that you need the on-chain get_wallet_address call. It is handy for normalizing addresses before comparison, so different representations of the same address don't get treated as different addresses.
2. The comment (memo) for invoice matching. The text comment lives in forward_payload in the standard format: the op prefix 0x00000000 (4 zero bytes) + a UTF-8 string. This is how you tie an incoming transfer to a specific order.
3. Amount and payer. Compare the converted amount against the invoice's expected amount; record from (the payer address) for history and anti-fraud if needed.
// 1. Origin is guaranteed: we read the history of EXACTLY the
// jetton wallet whose address was returned by get_wallet_address
// on the genuine USDT master — so any transfer here is real.
// 2. Comment → invoice
const invoice = invoices.get(memo);
if (!invoice) continue;
// 3. Does the amount match?
if (human < invoice.expectedAmount) markUnderpaid(invoice);
// 4. Payer — for logs/anti-fraud
log({ payer, memo, human, lt: tx.lt, hash: tx.hash });
Idempotency: dedup by lt + hash so you never credit a payment twice
Polling always trips over itself: it runs on a schedule, crashes, retries, and its windows overlap. Without dedup, sooner or later you'll credit one payment twice.
On TON every transaction is uniquely identified by the pair logical time (lt) + hash. That's your natural idempotency key:
const key = `${tx.lt}:${tx.hash}`;
if (await seen.has(key)) continue; // already processed — bail out
await creditInvoice(invoice, human); // credit the payment
await seen.add(key); // record it in the same DB transaction
You could also use the transfer's query_id as a key, but (lt, hash) works for any incoming jetton-wallet transaction, including the forward_ton_amount = 0 case. Store processed keys persistently and perform the credit in a single DB transaction with a uniqueness check — then parallel workers can't create a duplicate.
It's worth running reconciliation periodically: every N minutes, compare the jetton wallet's actual balance (via get_jetton_balance — it computes the address on-chain and returns the balance in one call) with the sum of all credited payments. A mismatch signals that a transfer was missed or double-counted somewhere — it catches the problem even when a single polling cycle failed.
Stable throughput under load: your own key instead of public limits
Payment polling is a constant, steady stream of requests: N wallets × polling frequency. And that's where public infrastructure becomes the bottleneck.
The public liteservers from the global config are shared and rate-limited: under load they answer not ready or drop into an ADNL timeout. The public HTTP APIs (toncenter, tonapi.io) without a key sustain about 1 request per second and return an honest HTTP 429 "Too Many Requests" when you exceed it. Note: the real rate-limit code is 429 — not the meme "228" that floats around the TON community and is not an API code. For a payment system polling history every few seconds, these limits mean missed and delayed credits. How to pick a provider with your own key is covered in a separate deep dive.
All the read tools you need for payment detection — get_transactions, run_get_method, get_jetton_balance, get_jetton_info, parse_address, get_account_state — are available locally for free: npx -y @tonnode/mcp. The @tonnode/mcp package is open source (MIT) and speaks TON's native ADNL protocol with no HTTP middlemen. When polling goes to production and you need guaranteed throughput, you plug in the hosted endpoint with your own key:
{
"mcpServers": {
"ton": {
"type": "http",
"url": "https://mcp.tonnode.io/mcp",
"headers": { "Authorization": "Bearer tn_live_…" }
}
}
}
The plans differ only in the requests-per-minute limit — all 16 tools are available on every one of them:
- 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.
For getting payment polling off the ground, the free 60 requests per minute are enough to keep the loop stable with no 429s. If you're building an AI agent on top of this that parses payments on its own — how that works is covered in the guide to MCP for AI agents on TON.
Start with a free Hobby key — 60 req/min for payment polling, no card: tonnode.io/dashboard?plan=hobby. When the load grows and one worker is no longer enough — compare the Pro and Scale limits.
Checklist for reliable USDT detection on TON:
- Poll the history of the recipient's jetton wallet via
get_transactions, not the main wallet balance. - Get that jetton wallet's address from the on-chain
get_wallet_addresscall (run_get_method) on the genuine USD₮ master (EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs) — then any transfer to it is guaranteed to be real USDT. - Look for incoming
internal_transferwith op0x178d4519— those always appear in the jetton wallet's history, regardless offorward_ton_amount.transfer_notification(0x7362d09c) goes to the main wallet and only whenforward_ton_amount > 0. - Take the payer address from the
fromfield and the amount fromamount(raw). - Take
decimalsfromget_jetton_info. USDT = 6, not 9. - Match the invoice by the comment from
forward_payload(op0x00000000+ UTF-8). - Dedup by
(lt, hash)— credit strictly once. - Keep your throughput stable with your own key, not with public limits.
Give your agent TON access
16 MCP tools: reads, non-custodial swaps, cross-chain and wallets. Free tier — 60 req/min, no card required.