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.

v1.0.0TRONBSC

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-here
The key is a bearer credential — anyone holding it can request wallets and create withdrawals. Keep it server-side. Never ship it in a mobile app, a browser bundle, or a public repository.

Flow: taking a payment

  1. Ask for a wallet for your customer, identified by their email and your own user id.
  2. 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.
  3. Wait for the webhook. The gateway scans the chain and calls your URL once the deposit is confirmed.
  4. 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

NameRequiredDescription
NetworkyesTRON or BSC
EmailAddressyesYour customer's email
UserIdyesYour 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"
Assignment is permanent. Call this once per customer per network and store the address — repeatedly requesting new wallets consumes the available pool and costs gas to replenish.

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.

NameRequiredDescription
NetworkyesTRON or BSC
AddressyesA deposit address already issued to you
AmountnoExpected USDT. Omit for an open amount (top-ups)
TitlenoShown to the payer, e.g. “Order 8831”
ReferencenoYour own reference, echoed back
ExpiresInMinutesnoLink 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.

  1. Create the request with a unique idempotency key.
  2. The gateway applies policy: the destination is screened, and the amount is checked against the merchant's auto-approval threshold and minimum.
  3. Small payouts are approved automatically. Larger ones, and anything screening flags, wait for a human at the merchant.
  4. 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

StatusMeaning
ApprovedCleared, waiting to be settled on-chain
PendingApprovalAbove the threshold — a person must release it
FlaggedHeld by screening; a person will review
BroadcastSent to the network, awaiting confirmation
ConfirmedSettled. Terminal.
RejectedRefused. Terminal.
FailedA settlement attempt reverted; it will be retried

POST /Api/Withdrawals/Request

Create a withdrawal request.

Body parameters

NameRequiredDescription
IdempotencyKeyyesYour unique key for this payout. See below.
NetworkyesTRON or BSC
DestinationyesRecipient wallet address
AmountyesUSDT, up to 6 decimal places. The fee is deducted from this — see below.
ReferencenoYour 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/Request

Response

{
  "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-8831 derived 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." }
StatusMeaningWhat to do
400Validation or policy refusalRead detail; do not retry unchanged
401Missing or wrong API keyCheck the Authentication header
403Feature disabled for that networkAsk the merchant to enable it
404Unknown key or walletCheck the identifier
503Gateway temporarily unavailableRetry 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.