Disclosure: I work on gasrent. The scripts follow the MIT examples at https://github.com/gasrent/gasrent-api-examples .
This is part 2 on USDT (TRC20) payouts without burning TRX for resources. Part 1 covers estimating energy with TronWeb and renting that energy. This article leaves energy alone and focuses on bandwidth.
If you already rent energy and still see TRX leave the hot wallet on every transfer, bandwidth is usually why. Energy pays for contract execution. Bandwidth pays for the bytes of the transaction. A rental of one does nothing for the other.
Why a USDT batch runs out of free bandwidth
TRON gives every account a free daily bandwidth allowance. A plain USDT transfer without a memo uses about 345 bandwidth points. One or two withdrawals a day often fit. A payroll-style batch of dozens does not. When neither the free pool nor delegated bandwidth covers the whole transaction, the network burns TRX for bandwidth.
Two rules trip people up:
- Free bandwidth and delegated (or staked) bandwidth are not added together for a single transfer. One pool has to cover the whole transaction by itself.
- Energy on the address does not help. You can hold a full day of rented energy and still burn TRX for bandwidth on a large batch.
What the bandwidth API looks like
gasrent is a TRON energy and bandwidth rental aggregator. Bandwidth uses the same API key and the same prepaid TRX balance as energy. Orders are separate: an energy order_id is not a bandwidth order_id, and the webhooks are bandwidth.filled / bandwidth.cancelled.
From the live API reference and OpenAPI document:
- Quote:
POST /api/v1/bandwidth/quote - Order:
POST /api/v1/bandwidth - Read one:
GET /api/v1/bandwidth/{order_id} - Volume: 1 to 100,000 inclusive
- Duration:
5m,1h,1d,3d, or7d(no 15-minute bandwidth rental) - Auth:
X-API-KEY - Idempotency:
idempotency_keyin the body, orX-Idempotency-Key(8 to 128 characters). A bandwidth key is not shared with energy keys.
A bandwidth order does not activate the receiver address. Pass an active hot wallet.
A quote response has this shape (live price_trx / price_sun strings omitted so the post stays timeless):
{
"resource": "bandwidth",
"volume": 1000,
"duration": "1h",
"price_trx": "...",
"price_sun": "..."
}
An order adds order_id, idempotency_key, status (Pending, Filled, or Cancelled), address, txid, and created_at. Filled means the chain shows a bandwidth delegation that covers the ordered volume (the API requires nearly the full amount).
Public GET /api/v1/prices also lists bandwidth tiers for dashboards. For a firm charge, use the authenticated quote. Docs: https://gasrent.click/api.html and https://gasrent.click/openapi.json .
Size the rental to the batch
For N plain USDT transfers without a memo, start from about N * 345 bandwidth points. Round up to a package a supplier will fill. Common public tiers include 270, 345, 1000, 5000, and 10000. With exact_volume omitted or false, a quote may return a larger volume when that fill is acceptable under the API rules. It never returns a smaller one.
Duration depends on the schedule:
- One batch, then idle:
5mor1hright before the run. - Several batches across a day: one
1dor3drental sized to a full day of transfers. - Continuous sending: keep a day-long floor and top up with a short rental on spikes.
Compare a short rental for one batch against a longer rental that covers several batches on the same day. The public prices table is enough for that comparison. Lock the charge with /bandwidth/quote only when you are about to order.
Python: quote, order, poll
Set GASRENT_KEY and TRON_ADDRESS, then:
import os, time, uuid
import requests
BASE = os.environ.get("GASRENT_BASE", "https://gasrent.click/api/v1")
KEY, ADDRESS = os.environ["GASRENT_KEY"], os.environ["TRON_ADDRESS"]
def api(path, method="GET", body=None):
r = requests.request(method, BASE + path, json=body, headers={"X-API-KEY": KEY}, timeout=60)
data = r.json() if r.content else {}
if not r.ok:
raise RuntimeError(f"{r.status_code} {data.get('error_code', 'error')}")
return data
volume = max(1000, 20 * 345) # ~345 bandwidth per plain USDT transfer
req = {"volume": volume, "duration": "1h", "address": ADDRESS}
print("quote:", api("/bandwidth/quote", method="POST", body=req))
body = {**req, "idempotency_key": f"bandwidth-batch-{uuid.uuid4()}"}
order = None
for attempt in range(1, 6):
try:
order = api("/bandwidth", method="POST", body=body)
break
except RuntimeError as e:
if "503" not in str(e) or attempt == 5:
raise
time.sleep(2 * attempt)
deadline = time.monotonic() + 600
while order["status"] == "Pending":
if time.monotonic() > deadline:
raise TimeoutError("Timed out")
time.sleep(3)
order = api(f"/bandwidth/{order['order_id']}")
print("final:", order["status"], order.get("txid"))
Persist idempotency_key with the batch job before the first attempt so a retry cannot open a second charge.
Or: pip install -r requirements.txt then python bandwidth.py --order with GASRENT_KEY and TRON_ADDRESS set.
Node.js: the same steps
import { randomUUID } from 'node:crypto';
const BASE = process.env.GASRENT_BASE || 'https://gasrent.click/api/v1';
const KEY = process.env.GASRENT_KEY;
const ADDRESS = process.env.TRON_ADDRESS;
async function api(path, { method = 'GET', body } = {}) {
const headers = { 'X-API-KEY': KEY };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${path}`, {
method, headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(`${res.status} ${data.error_code || 'error'}`);
err.status = res.status;
throw err;
}
return data;
}
const volume = Math.max(1000, 20 * 345);
const req = { volume, duration: '1h', address: ADDRESS };
const quote = await api('/bandwidth/quote', { method: 'POST', body: req });
console.log('quote:', quote.resource, quote.volume, quote.duration);
const idempotencyKey = `bandwidth-batch-${randomUUID()}`;
let order;
for (let attempt = 1; ; attempt++) {
try {
order = await api('/bandwidth', {
method: 'POST',
body: { ...req, idempotency_key: idempotencyKey },
});
break;
} catch (e) {
if (e.status !== 503 || attempt >= 5) throw e;
await new Promise((r) => setTimeout(r, 2000 * attempt));
}
}
const deadline = Date.now() + 600000;
while (order.status === 'Pending') {
if (Date.now() > deadline) throw new Error('Timed out');
await new Promise((r) => setTimeout(r, 3000));
order = await api(`/bandwidth/${order.order_id}`);
}
console.log('final:', order.status, order.txid);
Or run node/bandwidth.js --order from the examples repo.
Fit this next to your energy step
- Simulate each USDT transfer (Part 1) and sum energy.
- Rent energy and wait until
Filled. - Size bandwidth to
N * 345(or your measured figure) and rent it for the same window. - Wait until the bandwidth order is
Filled. - Broadcast the transfers.
If you use webhooks, handle order.filled and bandwidth.filled as different events on the same callback URL. Verify X-Signature as HMAC-SHA256 over the raw body bytes (Part 1 shows the Node receiver).
Checklist
- The hot wallet must already be activated. Bandwidth rental will not activate it.
- One idempotency key per batch, stored before the first
POST /bandwidth. - Retry
503 unavailablewith the same key.409 conflictmeans the body changed for a reused key. - Keep energy and bandwidth order ids in separate columns. An energy id on a bandwidth path returns
404 not_found. - A read-only key can list orders; placing one needs a full key.
Once both rentals are Filled, send the USDT batch while the delegations are live. When a rental ends, the delegated share returns to the lender, so size for the sends you will make in that window.
MIT examples (Node.js, Python, PHP, including the bandwidth scripts) are in https://github.com/gasrent/gasrent-api-examples . Energy estimation and rental remain in Part 1. Site: https://gasrent.click .
Ivan Gasin
Top comments (0)