Merchant API
Take USDT payments and pay customers out, on TRON (TRC-20) and BSC (BEP-20). Every endpoint speaks form-encoded requests and JSON responses.
Overview
The gateway gives each of your customers their own on-chain deposit address. Funds sent there are detected automatically, credited against that customer, and swept into the merchant treasury. You are notified by webhook. Payouts run in the opposite direction through a reviewed queue.
Deposit addresses are single-purpose contracts whose destination is fixed at deployment: funds swept from them can only ever reach the merchant treasury. That is a property of the contract, not of this server.
Authentication
Send your API key in the Authentication header on every request. There is no OAuth flow and no per-request signing.
Authentication: your-api-key-hereFlow: taking a payment
- Ask for a wallet for your customer, identified by their email and your own user id.
- Show the address to your customer and let them send USDT to it. The address belongs to that customer permanently — reuse it for their future deposits rather than requesting a new one.
- Wait for the webhook. The gateway scans the chain and calls your URL once the deposit is confirmed.
- Credit the customer in your own system, keyed on the transaction hash so a repeated webhook cannot double-credit.
GET /Api/Wallets/Request
Assign a deposit address to one of your customers.
Query parameters
| Name | Required | Description |
|---|---|---|
Network | yes | TRON or BSC |
EmailAddress | yes | Your customer's email |
UserId | yes | Your own identifier for that customer |
Example
curl -H "Authentication: $API_KEY" \
"https://your-gateway.example/Api/Wallets/Request?Network=BSC&EmailAddress=customer@example.com&UserId=u-1042"GET /Api/Wallets/Inquiry
Look up a wallet and its balance. Useful for reconciliation; it is not a substitute for the webhook, which is the authoritative deposit signal.
curl -H "Authentication: $API_KEY" \
"https://your-gateway.example/Api/Wallets/Inquiry?Network=BSC&UserId=u-1042"Deposit webhooks
When a deposit confirms, the gateway calls the URL configured for that network. Delivery is retried on failure and every attempt is logged.
What your endpoint must do
- Return 2xx quickly. Anything else is treated as a failure and retried.
- Be idempotent. Key on the transaction hash. A retry after your server timed out is normal and must not credit twice.
- Do the work asynchronously. Acknowledge first, then process — a slow handler causes duplicate deliveries.
// Sketch of a safe handler
app.post('/gateway/deposit', async (req, res) => {
res.sendStatus(200); // acknowledge first
const { TxHash, Amount, UserId, Network } = req.body;
// Unique index on TxHash makes the retry harmless.
const inserted = await db.deposits.insertIfNew({ TxHash, Amount, UserId, Network });
if (inserted) await credit(UserId, Amount);
});Hosted payment page
Optional. If you would rather not build address display, QR rendering and confirmation polling yourself, create a link and redirect your customer to it.
POST /Api/Pay/Create
Takes an address you already hold — this endpoint never allocates wallets, so a mistake here cannot drain your available pool. Request the wallet first, then create the link.
| Name | Required | Description |
|---|---|---|
Network | yes | TRON or BSC |
Address | yes | A deposit address already issued to you |
Amount | no | Expected USDT. Omit for an open amount (top-ups) |
Title | no | Shown to the payer, e.g. “Order 8831” |
Reference | no | Your own reference, echoed back |
ExpiresInMinutes | no | Link stops accepting payment after this |
curl -X POST -H "Authentication: $API_KEY" \
-d "Network=BSC" -d "Address=0xCustomerDepositAddress" \
-d "Amount=42.50" -d "Title=Order 8831" -d "ExpiresInMinutes=60" \
https://your-gateway.example/Api/Pay/Create
{ "Token": "9f2c…", "Url": "https://your-gateway.example/Pay/9f2c…",
"Network": "BSC", "Address": "0x…", "Amount": "42.500000" }Redirect your customer to Url. The page shows the amount, the address and a QR code, warns loudly about sending on the wrong network, and updates itself when the deposit confirms.
What the page deliberately does not show
The token is the page’s only credential, so nothing identifying your customer ever reaches it — no email, no user id, no wallet balance, no internal identifiers. It renders the amount, the address, the network and whether payment has arrived. Nothing else.
GET /Api/Pay/{token}/Status
The same state as JSON, unauthenticated, which is what the page itself polls. Useful if you would rather render your own UI but still want the confirmation logic. The webhook remains the authoritative signal — poll for the payer’s benefit, credit on the webhook.
{ "status": "waiting", "network": "BSC", "address": "0x…",
"amount": "42.500000", "received": "0", "outstanding": "42.500000" }status is one of waiting, underpaid, paid or expired. A link only counts deposits that arrive after it was created, so a customer’s earlier payment can never settle a newer link.
Flow: paying out
Withdrawals are queued and reviewed, not sent instantly. Creating one is a request, not an instruction.
- Create the request with a unique idempotency key.
- The gateway applies policy: the destination is screened, and the amount is checked against the merchant's auto-approval threshold and minimum.
- Small payouts are approved automatically. Larger ones, and anything screening flags, wait for a human at the merchant.
- Poll the status — or better, record the key and reconcile in batches. Do not block a user-facing request on it.
Statuses you will see
| Status | Meaning |
|---|---|
Approved | Cleared, waiting to be settled on-chain |
PendingApproval | Above the threshold — a person must release it |
Flagged | Held by screening; a person will review |
Broadcast | Sent to the network, awaiting confirmation |
Confirmed | Settled. Terminal. |
Rejected | Refused. Terminal. |
Failed | A settlement attempt reverted; it will be retried |
POST /Api/Withdrawals/Request
Create a withdrawal request.
Body parameters
| Name | Required | Description |
|---|---|---|
IdempotencyKey | yes | Your unique key for this payout. See below. |
Network | yes | TRON or BSC |
Destination | yes | Recipient wallet address |
Amount | yes | USDT, up to 6 decimal places. The fee is deducted from this — see below. |
Reference | no | Your own reference, echoed back |
curl -X POST -H "Authentication: $API_KEY" \
-d "IdempotencyKey=payout-8831" \
-d "Network=BSC" \
-d "Destination=0xRecipientAddress" \
-d "Amount=42.50" \
-d "Reference=order-8831" \
https://your-gateway.example/Api/Withdrawals/RequestResponse
{
"Created": true,
"Request": {
"Id": 41,
"IdempotencyKey": "payout-8831",
"Network": "BSC",
"Destination": "0xRecipientAddress",
"Amount": "42.500000",
"FeeAmount": "0.050000",
"NetAmount": "42.450000",
"Status": "Approved",
"Reference": "order-8831",
"CreatedDate": "2026-08-22 10:14",
"UpdatedDate": "2026-08-22 10:14"
}
}Created is false when the key had already been used — the original request is returned unchanged. That is a success, not an error.
Fees
The fee is deducted from Amount, not added to it. Your customer asks to withdraw Amount and the recipient address receives NetAmount. Show NetAmount in your interface before the customer confirms.
Fees are set per network by the merchant, and the two networks are not comparable: a TRON transfer genuinely costs about $2.20 while BSC costs a fraction of a cent, so a TRON fee will usually be far larger. A request whose amount does not exceed the fee is refused with a 400.
GET /Api/Withdrawals/Status
Look a withdrawal up by its idempotency key.
curl -H "Authentication: $API_KEY" \
"https://your-gateway.example/Api/Withdrawals/Status?IdempotencyKey=payout-8831"Idempotency
IdempotencyKey is mandatory on withdrawals, and it is the single most important field in this API.
Networks time out. Processes restart. Clients retry. Without a key, a request your server sent but never saw the response to becomes a second payment — and recipients do not report being paid twice.
Rules
- Derive the key from your own domain object, not from a random value generated at call time.
payout-8831derived from your payout row is safe; a fresh UUID per attempt is not, because a retry generates a different one. - Send the same key on every retry of the same logical payout.
- Never reuse a key for a genuinely different payout. The first request wins; a later call with the same key returns the original and pays nothing further.
- Keys are unique across the whole gateway and capped at 64 characters.
// Safe: stable key, survives retries
const key = `payout-${payoutRow.id}`;
// Unsafe: a retry produces a new key and a second payment
const key = crypto.randomUUID();Errors
Failures return the matching HTTP status and a JSON body:
{ "detail": "Minimum withdrawal on TRON is 25 — smaller payouts cost more in network fees than they are worth." }| Status | Meaning | What to do |
|---|---|---|
400 | Validation or policy refusal | Read detail; do not retry unchanged |
401 | Missing or wrong API key | Check the Authentication header |
403 | Feature disabled for that network | Ask the merchant to enable it |
404 | Unknown key or wallet | Check the identifier |
503 | Gateway temporarily unavailable | Retry with backoff and the same idempotency key |
Fees & chain choice
The two chains are not equivalent economically. A TRON transfer costs roughly 1,000× a BSC one, and that cost is paid on both the deposit and the withdrawal.
On small payments this dominates everything. If your users will accept USDT on BEP-20, prefer BSC — it is by far the cheapest thing you can do for the economics of an integration, and it costs you nothing but a default value.
Merchants may set a minimum withdrawal per network for exactly this reason. A request below it is refused with a 400 explaining the limit.