TronTransfer API
Energy-sponsored USDT (TRC-20) transfers on TRON. You keep custody — you sign every transaction; we rent and delegate the TRON energy your transfer needs, so your wallet pays one small flat TRX fee instead of burning 6.5–13.1 TRX of its own. Wallet short of TRX? The zero-TRX endpoints let the sender pay the fee in USDT instead.
- Base URL
https://trontransfer.com - Network TRON mainnet
- Token USDT TRC-20
- Auth none for the on-chain flow · API key for prepaid accounts
POST /account/keys. IP allow-lists, withdrawals, questions: [email protected]How a sponsored send works
A sponsored transfer is two signed transactions you submit together:
- Fee tx — a plain TRX transfer of the quoted fee to our
collectAddress. - USDT tx — your normal
transfer(to, amount)call on the USDT contract.
The relay then, in order: broadcasts the fee and confirms it on-chain (proves payment) → rents + delegates energy to your wallet → broadcasts your USDT tx, which burns the delegated energy (≈0 TRX beyond the fee).
collectAddress — it can never redirect your money.Quickstart (TronWeb)
import TronWeb from "tronweb";
const BASE = "https://trontransfer.com";
const tw = new TronWeb({ fullHost: "https://api.trongrid.io" }); // + your signer
async function sendUsdt(from, to, amountUsdt) {
// 1) config — where the fee goes + the USDT contract
const cfg = await (await fetch(`${BASE}/config`)).json();
// 2) the exact fee for THIS recipient (warm vs cold pricing)
const rcpt = await (await fetch(`${BASE}/recipient?addr=${to}`)).json();
const price = rcpt.pricing || cfg.pricing; // { feeSun, feeTrx, profitable, ... }
if (!price.profitable) throw new Error("Discounted transfer unavailable right now");
// 3) build BOTH txs
const feeTx = await tw.transactionBuilder.sendTrx(cfg.collectAddress, price.feeSun, from);
const units = Math.round(amountUsdt * 1e6); // USDT = 6 decimals
let usdtTx = (await tw.transactionBuilder.triggerSmartContract(
cfg.usdtContract, "transfer(address,uint256)", { feeLimit: 100_000_000 },
[{ type: "address", value: to }, { type: "uint256", value: units }], from
)).transaction;
// 4) extend expiration so the relay can rent energy before the tx expires
const feeExt = await tw.transactionBuilder.extendExpiration(feeTx, 300);
const usdtExt = await tw.transactionBuilder.extendExpiration(usdtTx, 300);
// 5) sign BOTH (TronWeb returns the fully-signed tx object)
const feeSigned = await tw.trx.sign(feeExt);
const usdtSigned = await tw.trx.sign(usdtExt);
// 6) submit
const res = await fetch(`${BASE}/v1/transfers`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feeTx: feeSigned, usdtTx: usdtSigned }),
});
const out = await res.json();
if (!out.ok || !out.usdtTxid) throw new Error(out.message || "send failed");
return out; // { ok:true, usdtTxid, feeTxid, ... }
}
txID (secp256k1, no EIP-191 prefix), assemble { ...rawTx, signature: [r‖s‖v] } yourself — picking the recovery byte that ecrecovers to the tx owner — and submit those. This is how card platforms integrate it server-side.Server-side (Node.js)
Same flow — the only difference is the signer. Instead of a browser wallet, TronWeb holds a private key and signs silently. Ideal for payout backends, exchange withdrawals, remittance tooling and bots that custody their own keys.
// npm i tronweb · Node 18+ (global fetch)
const { TronWeb } = require("tronweb");
const BASE = "https://trontransfer.com";
// Your key signs — no browser wallet. Keep it in env/KMS, never in code.
const tw = new TronWeb({
fullHost: "https://api.trongrid.io",
privateKey: process.env.TRON_PRIVATE_KEY,
});
const from = tw.defaultAddress.base58; // the key's own address
async function sendUsdt(to, amountUsdt) {
// 1) config + this recipient's exact fee (warm vs cold)
const cfg = await (await fetch(`${BASE}/config`)).json();
const rcpt = await (await fetch(`${BASE}/recipient?addr=${to}`)).json();
const price = rcpt.pricing || cfg.pricing;
if (!price.profitable) throw new Error("Discounted transfer unavailable right now");
// 2) build BOTH txs: the TRX fee + the USDT transfer
let feeTx = await tw.transactionBuilder.sendTrx(cfg.collectAddress, price.feeSun, from);
const units = Math.round(amountUsdt * 1e6); // USDT = 6 decimals
let usdtTx = (await tw.transactionBuilder.triggerSmartContract(
cfg.usdtContract, "transfer(address,uint256)", { feeLimit: 100_000_000 },
[{ type: "address", value: to }, { type: "uint256", value: units }], from
)).transaction;
// 3) extend expiration so the relay can rent energy in time
feeTx = await tw.transactionBuilder.extendExpiration(feeTx, 300);
usdtTx = await tw.transactionBuilder.extendExpiration(usdtTx, 300);
// 4) sign BOTH with your private key (you never broadcast)
const feeSigned = await tw.trx.sign(feeTx);
const usdtSigned = await tw.trx.sign(usdtTx);
// 5) submit — the relay broadcasts
const res = await fetch(`${BASE}/v1/transfers`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feeTx: feeSigned, usdtTx: usdtSigned }),
});
const out = await res.json();
if (!out.ok || !out.usdtTxid) throw new Error(out.message || "send failed");
return out; // { ok:true, usdtTxid, feeTxid, ... }
}
const TronWeb = require("tronweb") instead of the destructured { TronWeb }. Everything else is identical.Endpoints
GET/config
Static config + default pricing. Call once and cache briefly.
{
"collectAddress": "TWYPXnJmykjfdXdTwzQeDhYaoXTFQVw666",
"usdtContract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"trxUsd": 0.3253,
"defaultRegion": "sea",
"regions": ["sea", "global"],
"pricing": { "feeTrx": 5.476, "feeSun": 5476000, "feeUsd": 1.78,
"networkUsd": 2.23, "energy": 65000, "cold": false,
"profitable": true, "savePct": 20 }
}
GET/recipient?addr=<base58>[&from=<base58>]
Recipient-specific info and the exact fee for a transfer to addr. The fee depends on whether the recipient already holds USDT — "warm" (65k energy) vs "cold" (131k). Also returns safety signals.
| field | meaning |
|---|---|
valid, activated, isContract, isUsdtContract | address checks |
blacklisted | on a known blacklist — do not send |
hasReceivedUsdt, hasSentUsdt, usdtBalance, ageDays | activity / trust signals |
pricing | the fee to use — { feeSun, feeTrx, feeUsd, profitable, cold, energy } |
pricingByRegion | per-region pricing (sea, global) |
Always build the fee tx for pricing.feeSun. Warm ≈ 5.48 TRX; cold ≈ 10.76 TRX.
POST/v1/transfers
The sponsored send. Body: the two fully-signed tx objects. (With a prepaid API key in Authorization: Bearer, send only usdtTx: the fee is charged from your balance.)
Request
{ "feeTx": { …signed TRX transfer… },
"usdtTx": { …signed USDT transfer()… },
"region": "sea" }
Success — 200
{ "ok": true, "status": "completed",
"usdtTxid": "…", "feeTxid": "…",
"amountUsdt": 200, "feeTrx": 5.476, "energyFeeTrx": 0 }
Failure — 4xx (validation) or 200 { ok:false } (mid-flight)
{ "ok": false, "code": "FEE_TOO_LOW",
"message": "Fee too low: paid 2.10 TRX, need at least 5.48 TRX for this transfer.",
"detail": { "paidTrx": 2.1, "requiredTrx": 5.48 } }
ok field, never the HTTP status. Business failures return HTTP 200 with { ok:false, code }, and a gateway timeout can return a non-JSON body while the transfer is still processing — on a parse failure, treat the outcome as unknown and reconcile (poll /receipt), never blindly resend.POST /process is a legacy alias of /v1/transfers (same behavior).
GET/receipt?txid=<usdt_txid>
Solidified receipt — proves the energy was sponsored.
{ "result": "SUCCESS", "energyUsed": 64895, "energyFee": 0, "netFee": 0 }
energyFee: 0 confirms the transfer burned delegated (sponsored) energy, not the sender's TRX.
AML / address screening (optional, paid)
- GET
/scan/quote?provider=bitok|elliptic— the price of one scan. - POST
/scan{ address, provider, payTx }— a full AML / sanctions / mixer / darknet scan.payTxis a signed TRX fee tx tocollectAddress. Returns a riskverdict,risk_score,is_sanctionedand detailedflags/exposure. - POST
/screen{ recipient, amount }— a lightweight pre-send risk check.
bitok = local/SEA (cheaper); elliptic = worldwide (deeper). Recently-scanned addresses return cached at no extra cost.
Fee paid in USDT (zero-TRX)
For senders who hold USDT but little or no TRX. The wallet signs two ordinary USDT transfer() calls and sends them to us unbroadcast:
- Fee tx (
feeTx) — the quoted fee in USDT tofeeAddressfrom the quote. - Payment (
payTx) — the USDT you're sending, to the recipient.
We supply the energy and bandwidth for both, so the sender's TRX balance can be 0. In order: bandwidth for both + energy for the fee transfer → broadcast feeTx and wait until it's in a block → energy for the payment → broadcast payTx. Typically 15–45 s.
BANDWIDTH_SHORT): try again tomorrow or use the TRX-fee flow. The USDT fee is higher than the TRX fee of /v1/transfers; it depends on live prices and on whether the recipient already holds USDT, so always use the quote. Check /zt/status before offering the option.GET/zt/status[?sender=<base58>]
Whether the mode is available. Pass sender to ask for a specific wallet. Only offer the option when enabled is true.
{ "ok": true, "enabled": true, "paused": false, … }
GET/zt/quote?sender=<base58>&recipient=<base58>
{ "ok": true, "sender": "T…", "recipient": "T…",
"feeAddress": "TAEHWm1AWH9ZisXg5HpJeJ8q7nbq8i4pXZ",
"feeUsdt": <number>, // the fee in USDT, for display
"feeUnits": "<string>", // the fee in USDT base units (6 decimals)
"energy": <number>, "validS": 120 }
- Build the fee transfer for exactly
feeUnitstofeeAddress(always take both from the quote). validS: re-quote if more than ~2 minutes pass./zt/processre-prices and accepts a fee from 95 % to 300 % of a fresh quote (FEE_TOO_LOW/FEE_TOO_HIGHotherwise, withrequiredUsdt).
POST/zt/process
Request — two signed, unbroadcast USDT transfer() transactions from the same sender
{ "feeTx": { …signed transfer(feeAddress, feeUnits)… },
"payTx": { …signed transfer(recipient, amount)… } }
| feeTx | payTx | |
|---|---|---|
| Contract / call | USDT TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t, transfer(address,uint256), one contract call | |
to | feeAddress from the quote | the recipient (not the fee address) |
amount | feeUnits from the quote | what you're sending (6 decimals) |
| Signer | the same sender, for both | |
| TRX attached / memo | call_value 0; no memo (a memo costs TRX) | |
fee_limit | 30 TRX works (it's a cap; the energy is supplied) | |
| Expiration | between 150 s and 15 min away when it reaches us; build with ~5–8 min | |
Answer — when both transfers are in a block
{ "ok": true, "status": "completed", "sender": "T…", "recipient": "T…",
"amount": "10000000", "feeUnits": "…", "feeTxid": "…", "payTxid": "…" }
If that takes longer than 40 s you get { "ok": false, "status": "processing", "code": "PROCESSING", "feeTxid": "…", … }: the job keeps running on the server — poll /zt/job. Never broadcast the two transactions yourself, and never sign a new pair while one is processing. Sending the same pair again (a retry, a double click) is safe: it never spends twice, it returns or resumes the same job.
const BASE = "https://trontransfer.com", USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
// sender = the connected address; amountUnits = USDT × 1e6
const q = await (await fetch(`${BASE}/zt/quote?sender=${sender}&recipient=${to}`)).json();
if (!q.ok) throw new Error(`${q.code}: ${q.message}`);
const build = async (dest, units) => {
let { transaction: tx } = await tronWeb.transactionBuilder.triggerSmartContract(USDT, "transfer(address,uint256)",
{ feeLimit: 30e6, callValue: 0 }, [{ type: "address", value: dest }, { type: "uint256", value: units }], sender);
// expiry ~8 min after the node built it, whatever the node's default is
const ext = Math.round((tx.raw_data.timestamp + 480000 - tx.raw_data.expiration) / 1000);
if (ext) tx = await tronWeb.transactionBuilder.extendExpiration(tx, ext);
return tronWeb.trx.sign(tx); // sign only; never broadcast
};
const feeTx = await build(q.feeAddress, q.feeUnits), payTx = await build(to, amountUnits);
let out = await (await fetch(`${BASE}/zt/process`, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ feeTx, payTx }) })).json();
while (out.status === "processing") {
await new Promise((r) => setTimeout(r, 4000));
out = await (await fetch(`${BASE}/zt/job?fee=${feeTx.txID}`)).json();
}
GET/zt/job?fee=<fee txid>
The current answer for that send, in the same shape as /zt/process: completed, pay_failed, processing, or failed with a code. { "ok": false, "code": "NOT_FOUND" } if the relay never accepted that pair. Poll every 3–5 s; most jobs end in under a minute, and every job ends within ~25 minutes of its transactions being signed.
Reading an answer
All responses are HTTP 200 JSON — check ok and status, never the HTTP status.
status: "completed"→ sent.status: "pay_failed"→ the fee was charged and the payment was not sent (certain). Show both txids and contact support.status: "processing"→ keep polling/zt/job. Don't say anything about money yet.- A
codein the nothing-charged list below → nothing was broadcast; you may say so. - Any other code (including
UNCONFIRMED,INTERNAL_ERROR,DUPLICATE, or a code added later) → uncertain: don't send again yet; check both transfers on Tronscan. Never report "nothing charged" or "not sent" for these.
Nothing-charged list (mirror it exactly):
DISABLED MISCONFIGURED PAUSED RATE_LIMITED BUSY REFUSED BAD_ADDRESS PRICE_UNAVAILABLE
INVALID_JSON TX_MISSING TX_TAMPERED NOT_A_TRANSFER WRONG_TOKEN VALUE_ATTACHED MEMO_NOT_ALLOWED
FEE_LIMIT_TOO_LOW ZERO_AMOUNT BAD_SIGNATURE PERMISSION TX_EXPIRING SENDER_MISMATCH
FEE_WRONG_DESTINATION PAY_TO_FEE_ADDRESS SAME_TX FEE_TOO_LOW FEE_TOO_HIGH ACCOUNT_INACTIVE
INSUFFICIENT_USDT WOULD_FAIL BANDWIDTH_SHORT ENERGY_UNAVAILABLE ENERGY_TIMEOUT FEE_NOT_PAID
| code | meaning |
|---|---|
DISABLED · PAUSED | the mode is off or paused right now — use the TRX-fee flow or retry later |
ACCOUNT_INACTIVE | the sender's account isn't activated on TRON |
INSUFFICIENT_USDT | not enough USDT for amount + fee |
BANDWIDTH_SHORT | the wallet's daily bandwidth allowance is used up — try tomorrow or pay in TRX |
FEE_TOO_LOW · FEE_TOO_HIGH | fee outside 95 %–300 % of a fresh quote (requiredUsdt included) — re-quote and re-sign |
TX_EXPIRING · TX_TAMPERED | expires too soon / changed after signing, not built on a real recent block, or expiry more than 15 min away — rebuild |
RATE_LIMITED · BUSY | too many attempts / a send for this wallet is already running |
ENERGY_UNAVAILABLE · ENERGY_TIMEOUT | resources couldn't be supplied; nothing was broadcast (unlike ENERGY_UNAVAILABLE on /v1/transfers, no fee was taken) |
UNCONFIRMED | we couldn't confirm what happened on-chain — unknown: check both txids before doing anything |
Prepaid API accounts
For developers and agents that send often. Instead of signing a TRX fee transaction for every transfer, you hold a TRX balance with us and send each transfer with an API key: the relay charges the fee from the balance, rents the energy and broadcasts your signed USDT transfer. The on-chain flow above keeps working exactly as before without a key.
When it fits
- Payout backends, bots and agents that send many transfers: one balance to top up, one signature per send, no per-transfer fee confirmation (typically 3–6 s saved).
- Several sending wallets behind one account: any wallet can sign the USDT transfer; the fee always comes off the account balance, not the signing wallet.
- Wallets that hold USDT but little TRX: the signing wallet does not pay the fee itself. Keep a little TRX in it anyway — bandwidth sponsorship is best-effort, and a wallet with no bandwidth left burns ~0.3 TRX for the transfer.
For a single occasional send, the on-chain flow (no account) is simpler. Dashboard for humans: trontransfer.com/account.
1. Get a key
curl -X POST https://trontransfer.com/account/keys \
-H "Content-Type: application/json" -d '{ "label": "payout bot" }'
HTTP 201
{ "ok": true,
"key": "tt_live_…", // shown ONCE — store it server-side
"account": { "id": 42, "keyPrefix": "tt_live_a1b2", "label": "payout bot",
"depositCode": "TT-7K4MQ2", "balanceSun": 0, "balanceTrx": 0,
"status": "active", "createdAt": "…", "lastUsedAt": null },
"depositAddress": "TWYPXnJmykjfdXdTwzQeDhYaoXTFQVw666" }
- The key is shown once and stored hashed; we cannot show it again. Lost it?
POST /account/rotateneeds the old key, so contact support. - Up to 5 new keys per hour per IP. Keys are
tt_live_+ 32 characters; send them only in theAuthorizationheader, never in a URL or a browser page. POST /account/rotate(Bearer) returns a new key; the old one stops working at once.
2. Deposit TRX
Deposits are plain TRX transfers to depositAddress (the same address as collectAddress in /config; also in GET /account/pricing). We credit a deposit once its transaction is confirmed — the watcher checks about every minute, and POST /account/check-deposits looks right now. Two ways to tell us whose deposit it is:
| Memo code | Confirmed wallet | |
|---|---|---|
| How | put your depositCode (TT-7K4MQ2) in the transaction memo — the "note" / data field (TronLink's note field; TronWeb: transactionBuilder.addUpdateData(tx, code)) | a wallet a memo-matched deposit came from is listed on your account (depositAddresses: [{ address, added_at, confirmed }] in GET /account/me). Confirm it once with POST /account/deposit-addresses/confirm { "address": "T…" }; from then on plain TRX from that wallet is credited without a memo |
| Best for | a wallet or script you control; the memo is matched case-insensitively, other words around it are fine | your own wallet sending again without a memo. Never confirm an exchange address: an exchange withdrawal comes from a hot wallet shared by many customers |
| Limits | one code per account, never changes | up to 10 wallets per account; a wallet seen with two different accounts' memo codes is treated as shared — it is unbound from both and never credits without a memo again. POST /account/deposit-addresses/remove drops a wallet from the list |
// TronWeb: a 100 TRX deposit with the memo code
let tx = await tw.transactionBuilder.sendTrx(depositAddress, 100_000_000, from);
tx = await tw.transactionBuilder.addUpdateData(tx, "TT-7K4MQ2", "utf8"); // the memo
await tw.trx.sendRawTransaction(await tw.trx.sign(tx)); // a deposit IS broadcast by you (unlike a transfer)
3. Send with a key
Same as POST /v1/transfers, with Authorization: Bearer <key> and only usdtTx in the body: your signed, unbroadcast USDT transfer() with the expiration extended to ~5 minutes (the relay rents energy before it broadcasts; at most 15 minutes, else 400 TX_EXPIRATION_TOO_LONG). No feeTx — a fee transaction sent with a key is ignored.
curl -X POST https://trontransfer.com/v1/transfers \
-H "Authorization: Bearer tt_live_…" \
-H "Content-Type: application/json" \
-d '{ "usdtTx": { "txID": "…", "raw_data": { … }, "raw_data_hex": "…", "signature": ["…"] } }'
Node.js (TronWeb) — build, sign, submit
// npm i tronweb · Node 18+ (global fetch)
const { TronWeb } = require("tronweb");
const BASE = "https://trontransfer.com";
const tw = new TronWeb({ fullHost: "https://api.trongrid.io", privateKey: process.env.TRON_PRIVATE_KEY });
const from = tw.defaultAddress.base58;
const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.TT_API_KEY}` };
async function sendUsdt(to, amountUsdt) {
// 1) the USDT contract + this recipient's fee (what will be charged) and safety signals
const cfg = await (await fetch(`${BASE}/config`)).json();
const rcpt = await (await fetch(`${BASE}/recipient?addr=${to}`)).json();
if (rcpt.blacklisted) throw new Error("recipient is blacklisted; do not send");
if (!(rcpt.pricing || cfg.pricing).profitable) throw new Error("sponsorship paused right now; retry later");
// 2) build + sign ONLY the USDT transfer — no fee tx, the fee comes off your balance
let usdtTx = (await tw.transactionBuilder.triggerSmartContract(
cfg.usdtContract, "transfer(address,uint256)", { feeLimit: 100_000_000 },
[{ type: "address", value: to }, { type: "uint256", value: Math.round(amountUsdt * 1e6) }], from
)).transaction;
usdtTx = await tw.transactionBuilder.extendExpiration(usdtTx, 300); // ~5 min: energy is rented first
const usdtSigned = await tw.trx.sign(usdtTx); // sign only — never broadcast
// 3) submit with your key
const res = await fetch(`${BASE}/v1/transfers`, { method: "POST", headers, body: JSON.stringify({ usdtTx: usdtSigned }) });
const text = await res.text();
let out; try { out = JSON.parse(text); } catch { out = null; }
if (!out) return { ok: null, status: "unknown", checkTxid: usdtSigned.txID }; // gateway timeout: check /receipt, never resend blindly
if (res.status === 402) throw new Error(`top up: balance ${out.detail.balanceTrx} TRX, need ${out.detail.requiredTrx} TRX (memo ${out.depositCode} to ${out.depositAddress})`);
if (!out.ok) throw new Error(`${out.code}: ${out.message}`);
return out; // { ok:true, status:"completed", usdtTxid, prepaid:{ chargedTrx, balanceTrx, tier } }
}
Success — 200
{ "ok": true, "status": "completed",
"usdtTxid": "…", "feeTxid": "prepaid:<usdtTx.txID>", // the ledger reference, not a chain tx
"amountUsdt": 200, "feeTrx": 5.48, "energyFeeTrx": 0,
"prepaid": { "chargedTrx": 5.48, "balanceTrx": 494.52,
"tier": { "count": 12, "windowDays": 30, "minCount": 0, "mult": 1, "discountPct": 0 } } }
Balance too low — 402 (nothing charged)
{ "ok": false, "code": "INSUFFICIENT_BALANCE",
"message": "Your balance must cover the current cold-recipient fee (10.76 TRX) to send.",
"detail": { "balanceTrx": 3.2, "requiredTrx": 10.76, "chargeTrx": 5.48 },
"depositAddress": "TWYPXnJmykjfdXdTwzQeDhYaoXTFQVw666", "depositCode": "TT-7K4MQ2" }
Reading the answer
Judge by ok and code, as for every /v1/transfers call. What happened to the charge:
| HTTP | code | the charge |
|---|---|---|
| 401 | INVALID_API_KEY | nothing charged — the key is wrong or was rotated |
| 402 | INSUFFICIENT_BALANCE | nothing charged — the balance is below the current cold-recipient fee (detail.requiredTrx); top up at depositAddress with depositCode, then resend the same signed tx if it hasn't expired |
| 429 | RATE_LIMITED | nothing charged — 30 transfers per minute per account, 60 bad-key attempts per 10 minutes per IP; wait |
| 503 | PREPAID_UNAVAILABLE | nothing charged — prepaid accounts are off right now; the on-chain flow still works |
| 400 | TX_MISSING · USDT_NOT_A_TRANSFER · WRONG_USDT_CONTRACT · TX_TAMPERED · BAD_SIGNATURE · PERMISSION · TX_EXPIRED · TX_EXPIRATION_TOO_LONG | nothing charged — fix the transaction and resend. TX_EXPIRATION_TOO_LONG: usdtTx may expire at most 15 minutes from now (detail.maxExpirationMs = 900000); sign with ~300 s |
200 ok:false | MARKET_UNPROFITABLE | nothing charged — the energy market is too expensive to sponsor; retry later |
200 ok:false | NODE_UNAVAILABLE | nothing charged — the relay couldn't verify the transaction's permissions with the network; try again in a moment |
200 ok:false | DUPLICATE | the same signed usdtTx was already charged; it is never charged twice. The answer carries usdtTxid (and the recorded status when the transfer is yours): check /receipt; to send again, sign a new transaction |
200 ok:false | ENERGY_UNAVAILABLE | no USDT was sent. refunded: true: nothing was bought for the transfer and the charge was returned at once (prepaid.balanceTrx is the new balance). refunded: false: energy was already bought, so the charge is settled from the chain once the tx has expired (settlement) — only our network cost is kept if it never lands |
200 ok:false | USDT_BROADCAST_FAILED | the node refused the broadcast. Not settled at once — another node may still accept the same bytes — so the charge is settled from the chain once the tx has expired: refunded in full when nothing was bought, our network cost kept otherwise. usdtTxid is in the answer |
200 ok:false | INTERNAL_ERROR | a relay error after the charge: refunded at once when nothing was bought and the USDT was never handed to a node; otherwise settled from the chain like the two rows above |
200 ok:false, status: "pending" | USDT_NOT_CONFIRMED | the USDT was broadcast. If it failed on-chain, only the network cost we paid is kept and the rest is credited back. If it is still pending, poll /receipt; the reconciler settles it from the chain. Never resend. |
200 ok:true | status: "completed" | sent; prepaid.chargedTrx kept, prepaid.balanceTrx is what's left |
- Not JSON (gateway timeout): the transfer may still be running. Check
/receipt?txid=<usdtTx.txID>first;GET /account/meshows the charge or refund. - Resending the same signed
usdtTxnever pays twice: it is answered withDUPLICATEand nothing is charged again. - Every movement is a ledger row (
deposit,charge,refund,adjust) inGET /account/me; a charge or refund'srefisprepaid:<usdt txID>.
How a charge is settled
| what happened to the USDT | the charge |
|---|---|
in a block, SUCCESS | stands (status: "completed") |
| in a block, failed on-chain | our actual network cost (the energy and bandwidth bought for it) is kept, the rest is refunded |
| never landed — the tx expired without reaching a block | refunded in full when nothing was bought for it, our network cost kept otherwise; decided once the tx has expired |
- Open charges are settled from the chain by a reconciler that runs at start-up and every 5 minutes. It never refunds on a throttled or lagging node's say-so: "not on the chain" is believed only from a node that is up to date, so a decision may wait for a later run.
- Every refund happens at most once per transfer and shows as a
refundrow in the ledger.
Account endpoints
All JSON; { ok:false, code, message } on failure. Bearer = Authorization: Bearer tt_live_….
| endpoint | auth | body | answer |
|---|---|---|---|
POST/account/keys | none | { label? } | 201 { key, account, depositAddress } — 5 per hour per IP |
GET/account/pricing | none | — | { depositAddress, trxUsd, onchain: { warmTrx, coldTrx, warmUsd, coldUsd }, rules: { tiers, tierWindowDays, minBalanceMult, failedOnchain } } |
GET/account/me | Bearer | — | { account, depositAddress, depositAddresses: [{ address, added_at, confirmed }], tier, ledger, deposits, transfers, prices, estimatedTransfers } — prices.charge = your current warmTrx / coldTrx, minBalanceTrx, tier; estimatedTransfers = balance ÷ warm charge |
POST/account/deposit-addresses/confirm | Bearer | { address } | { depositAddresses } — marks a listed wallet as yours; plain TRX from it is then credited without a memo (never an exchange address) |
POST/account/deposit-addresses/remove | Bearer | { address } | { depositAddresses } |
POST/account/check-deposits | Bearer | — | { credited: [{ txid, amountTrx, matchedBy }], balanceTrx } — 3 per minute |
POST/account/rotate | Bearer | — | { key } — the old key stops working at once |
POST/v1/transfers | Bearer | { usdtTx } | the usual answer plus prepaid: { chargedTrx, balanceTrx, tier } |
Every endpoint answers 503 PREPAID_UNAVAILABLE while prepaid accounts are switched off; 429 RATE_LIMITED when a limit above is hit.
Pricing
- Per transfer: the live on-chain fee for that recipient —
pricing.feeSunfrom/recipient(warm ≈ 5.48 TRX, cold ≈ 10.76 TRX right now) — times your tier multiplier. It is never below our own network cost; a charge floored there is rounded up to the next 0.1 TRX. - Volume tiers (trailing 30 days, counted per account): 100+ transfers → ×0.85 (15% less); 500+ → ×0.70 (30% less). Your current tier and count are in
prepaid.tieron every answer and inGET /account/me. - Entry bar: a transfer is accepted only when the balance is at least the current cold fee (
prices.charge.minBalanceTrx), even when the recipient is warm — otherwise402 INSUFFICIENT_BALANCEand nothing is charged. - Refunds: automatic and in full when nothing was bought for the transfer and it never went out; once energy or bandwidth was bought, or it was broadcast and failed on-chain, only the network cost we paid is kept (
rules.failedOnchain: "cost") — see how a charge is settled. - Balances never expire. Withdrawals on request: email [email protected] from the account's label or with its deposit code.
Error codes (/v1/transfers)
| HTTP | code | meaning |
|---|---|---|
| 400 | INVALID_JSON · TX_MISSING | malformed request |
| 400 | FEE_NOT_TRX · FEE_WRONG_DESTINATION | fee tx isn't a TRX transfer to collectAddress |
| 400 | FEE_TOO_LOW · FEE_TOO_HIGH | fee ≠ quoted amount (see detail.requiredTrx) |
| 400 | USDT_NOT_A_TRANSFER · WRONG_USDT_CONTRACT | USDT tx isn't a transfer() on the USDT contract |
| 400 | SENDER_MISMATCH | the two txs aren't signed by the same wallet |
| 400 | TX_EXPIRED | a signed tx expired — rebuild with extendExpiration(~300) |
| 400 | TX_EXPIRATION_TOO_LONG | prepaid: usdtTx expires more than 15 min from now (detail.maxExpirationMs) — sign with ~300 s; nothing charged |
200 ok:false | MARKET_UNPROFITABLE | energy too pricey to sponsor now — retry later or self-pay |
200 ok:false | FEE_BROADCAST_REJECTED · FEE_NOT_CONFIRMED | the fee tx didn't land — no USDT sent |
200 ok:false | ENERGY_UNAVAILABLE | couldn't sponsor energy — no USDT sent; fee held (detail.feeHeld). Prepaid: refunded at once unless energy was bought — see reading the answer |
200 ok:false | USDT_BROADCAST_FAILED | USDT tx rejected after retry (prepaid: the charge is settled from the chain once the tx has expired) |
200 ok:false | NODE_UNAVAILABLE | prepaid: couldn't verify the tx's permissions with the network — nothing charged; try again in a moment |
200 ok:false | DUPLICATE | prepaid: this signed tx was already charged — never charged twice; sign a new one to send again |
| 401 | INVALID_API_KEY | prepaid: the Authorization: Bearer key isn't valid — nothing charged |
| 402 | INSUFFICIENT_BALANCE | prepaid: balance below the current cold fee — top up (depositAddress, depositCode); nothing charged |
| 429 | RATE_LIMITED | prepaid: too many transfers or key attempts — wait a minute; nothing charged |
| 503 | PREPAID_UNAVAILABLE | prepaid accounts are off right now — the on-chain flow still works |
Notes & gotchas
- Mainnet only. No testnet.
- 6 decimals. USDT amount units =
usdt × 1_000_000. - Flat fee per transfer, independent of the USDT amount — driven by energy (warm 65k / cold 131k), not size.
- Extend expiration to ~300s on both txs; the relay withholds the USDT broadcast until energy is delegated (~15–45s).
- The fee is real and paid up-front to
collectAddress. If a transfer fails after the fee confirms (ENERGY_UNAVAILABLE), the USDT is not sent and the fee is not auto-refunded — retry to reuse it, or reconcile. (A prepaid charge, by contrast, is returned to the balance automatically when nothing was bought for the transfer; otherwise it is settled from the chain.) - Idempotent: a signed tx has a fixed
txID; re-submitting the same pair is safe (the network rejects a duplicate broadcast; with a key the answer isDUPLICATEand nothing is charged again).