How to Detect Incoming USDT (TRC-20) Transfers on Tron
Why USDT deposits on Tron are easy to miss, and how to get a webhook for every incoming TRC-20 USDT transfer to an address without running a Tron node.
Most USDT in the world moves on Tron, so sooner or later an app that takes crypto has to notice a TRC-20 USDT payment arriving at an address. It is harder than it looks, because a USDT transfer is not a normal Tron transaction to your address. This guide explains why, and shows how to be told about every incoming transfer with a webhook.
Why USDT transfers on Tron are easy to miss
USDT on Tron is a smart contract, TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t. When someone sends you USDT they do not send a transaction to your address: they call that contract, and the contract records a Transfer event that names you as the receiver. If you only look at the TRX transactions of your address, you will see nothing.
That leaves three things to get right if you build the detection yourself:
- Read the contract's Transfer events, not the address's transactions, and decode the receiver from the event.
- Check the contract address. Anyone can create a token called USDT, and fake ones are sent to deposit addresses on purpose.
- Wait for the block to become final before you credit anything. On Tron that is about 20 blocks, roughly a minute.
The short way: track the address
The LirChain Wallet Tracker does those three things for you. You register a Tron address and receive a webhook for every confirmed transfer of USDT, USDC or TRX to or from it. Create a free account, open the Wallet Tracker project in the dashboard, copy its API key and add the address:
curl https://api.lirchain.com/merchant/wallet-tracker/create \
-H "x-api-key: YOUR_API_KEY" \
-H "content-type: application/json" \
-d '{
"network": "tron",
"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"external_id": "user-5521"
}'const API_KEY = "YOUR_API_KEY";
async function createWalletTracker(params) {
const response = await fetch("https://api.lirchain.com/merchant/wallet-tracker/create", {
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/json" },
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`LirChain API ${response.status}: ${await response.text()}`);
}
return response.json();
}
const result = await createWalletTracker({
network: "tron",
address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
external_id: "user-5521",
});const API_KEY = "YOUR_API_KEY";
type CreateWalletTrackerParams = {
network: string;
address: string;
external_id?: string;
};
type CreateWalletTrackerResult = {
id: string;
project_id: string;
merchant_id: string;
network: string;
address: string;
external_id: string | null;
};
async function createWalletTracker(params: CreateWalletTrackerParams): Promise<CreateWalletTrackerResult> {
const response = await fetch("https://api.lirchain.com/merchant/wallet-tracker/create", {
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/json" },
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`LirChain API ${response.status}: ${await response.text()}`);
}
return response.json();
}
const result = await createWalletTracker({
network: "tron",
address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
external_id: "user-5521",
});import requests
API_KEY = "YOUR_API_KEY"
def create_wallet_tracker(**params):
response = requests.post(
"https://api.lirchain.com/merchant/wallet-tracker/create",
headers={"x-api-key": API_KEY},
json=params,
timeout=30,
)
response.raise_for_status()
return response.json()
result = create_wallet_tracker(
network="tron",
address="TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
external_id="user-5521",
)<?php
const API_KEY = "YOUR_API_KEY";
function create_wallet_tracker(array $params): array
{
$curl = curl_init("https://api.lirchain.com/merchant/wallet-tracker/create");
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ["x-api-key: " . API_KEY, "content-type: application/json"],
]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
if ($body === false || $status >= 400) {
throw new Exception("LirChain API $status: $body");
}
return json_decode($body, true);
}
$result = create_wallet_tracker([
"network" => "tron",
"address" => "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"external_id" => "user-5521",
]);package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const apiKey = "YOUR_API_KEY"
func CreateWalletTracker(params map[string]any) (map[string]any, error) {
body, err := json.Marshal(params)
if err != nil {
return nil, err
}
request, err := http.NewRequest("POST", "https://api.lirchain.com/merchant/wallet-tracker/create", bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("x-api-key", apiKey)
request.Header.Set("content-type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode >= 400 {
return nil, fmt.Errorf("LirChain API %d: %s", response.StatusCode, data)
}
var result map[string]any
return result, json.Unmarshal(data, &result)
}
func main() {
result, err := CreateWalletTracker(map[string]any{
"network": "tron",
"address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"external_id": "user-5521",
})
fmt.Println(result, err)
}Write the address exactly as Tron writes it, starting with T: capital and small letters matter. external_id is your own label, such as the id of the user the address belongs to, and comes back in every webhook. The address in this example is the USDT contract itself, which is always busy. Use your own address instead.
The webhook you receive
Open the project in the dashboard, paste the HTTPS address of your endpoint into its webhook field and save. An incoming USDT transfer then arrives like this:
{
"type": "wallet-tracker",
"event": {
"id": "95e04e32-5c98-4f68-8c62-f487bda0ed5c",
"tracker_id": "67153a22-af19-4352-ab8e-041006c76c7b",
"external_id": "user-5521",
"project_id": "ea54aa6c-21d7-46a4-b1fe-c0703f74a2af",
"created_ms": 1791504120904
},
"transfer": {
"network": "tron",
"block_number": 87151012,
"tx_hash": "9c1f…a27e",
"log_index": "0",
"from_address": "TQn9…Hx2k",
"to_address": "TLa2…f8Wq",
"asset": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"amount": "25000000",
"symbol": "USDT",
"amount_units": "25"
}
}asset is the contract of the token and symbol is USDT only for the real one, so a fake token never reaches you as USDT. amount is in the smallest unit (USDT has six decimals) and amount_units is the same amount in whole USDT.
Crediting the user
import express from "express";
const app = express();
app.use(express.json());
// Use your database for both of these in production
const handled = new Set();
const address_of_user = new Map([["user-5521", "TLa2…f8Wq"]]);
app.post("/webhooks/lirchain", (req, res) => {
const id = req.get("x-webhook-id");
if (handled.has(id)) {
return res.sendStatus(200);
}
handled.add(id);
const { type, event, transfer } = req.body;
const user = event?.external_id;
const is_deposit =
type === "wallet-tracker" &&
transfer.network === "tron" &&
transfer.symbol === "USDT" &&
transfer.to_address === address_of_user.get(user);
if (is_deposit) {
console.log(`credit ${transfer.amount_units} USDT to ${user}`);
}
res.sendStatus(200);
});
app.listen(3000);Answer with a 2xx status within 10 seconds. If your server does not, the same webhook is sent again every minute, up to 20 times, each time with the same x-webhook-id header, so keep the ids you handled and skip repeats.
Webhooks are not signed. Before you credit money because of one, read the event back from the API with your key (the events list of the tracker) and credit what the API says.
Do you want us to create the addresses too?
Tracking works for addresses you already have. If you would rather not generate and guard Tron keys at all, deposit wallets create a separate deposit address per user and add what arrives to your balance. They run on Ethereum, BNB Chain and Polygon today, where USDT is the same dollar stablecoin.
Limits and timing
- A transfer is reported once it is 20 blocks deep, about a minute after it is sent.
- TRX and USDC transfers of the address are reported the same way, and so are outgoing ones.
- A tracker reports transfers from the moment you add it.
- The Free plan tracks 50 addresses and reports 250 events per 30 days, with no fee per event.
Questions and answers
- Why can't I see a USDT deposit in my Tron address's transactions?
- Because a TRC-20 transfer is a call to the USDT contract, not a transaction to your address. It appears among the token transfers of the address, as a Transfer event of the contract.
- How many confirmations does a USDT TRC-20 deposit need?
- A Tron block is final after about 19 more blocks. The tracker reports a transfer once it is 20 blocks deep, roughly one minute.
- How do I avoid crediting a fake USDT token?
- Check that the token's contract is TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t. The tracker does this for you: only the real contract is reported with the symbol USDT.
Try it on the Free plan
Every product is included, and no card is needed to sign up.
Create a free account