How to Accept USDT Payments Reliably: Addresses, Confirmations, and Webhooks
Accept USDT payments with chain-specific instructions, exact order matching, confirmation tracking, and verified webhook fulfillment.
If you want to accept USDT payments in production, the first design choice is not “which wallet address should we show?” It is “which chain, asset, amount, address, and business order are we asking the customer to pay?”
USDT exists on multiple chains. A transfer of the same named asset on the wrong network is not a successful payment for your order, even if the amount looks right. A reliable USDT flow therefore needs explicit payment instructions, deterministic matching, confirmation and reorg handling, and a verified webhook boundary for fulfillment. StableOps provides that order and event layer while the merchant keeps control of the receiving addresses.
USDT is an asset plus a chain
Customers often think of USDT as one balance. Payment systems cannot. USDT on TRON, Ethereum, Optimism, BNB Chain, or Solana is represented by different chain events, address formats, token contracts or mints, fees, and confirmation behavior. Your checkout must ask for a specific (chain, asset) pair, not just “send USDT.”
The practical model is:
Business order
|
v
Payment Order: amount + accepted chain/asset pairs + expiry
|
v
Payment instructions: chain + USDT + address + exact amount
|
v
Chain transfer matched by organization, environment, chain, asset, address, and amount
|
v
payment.detected -> payment.confirmed -> payment.finalizedStableOps rejects unsupported (chain, asset) pairs at order creation. For supported pairs, it allocates a receiving candidate per accepted pair and returns the exact paymentInstructions your application or Checkout page should render. The supported assets table in the Introduction remains the source of truth for live chain coverage.
Create one order with explicit USDT options
Create and persist your business order before creating the Payment Order. Its immutable identifier becomes merchantOrderId, and the amount and expiry should be stable across retries. Do not create a new payment attempt with a fresh expiry just because a browser refreshed.
import { StableOps } from '@stableops/api-sdk'
const stableops = new StableOps({
apiKey: process.env.STABLEOPS_API_KEY!,
})
export async function createUsdtPayment(order: {
id: string
amount: string
paymentExpiresAt: string
}) {
return stableops.paymentOrders.create(
{
merchantOrderId: order.id,
amount: order.amount,
acceptedAssets: [
{ chain: 'tron', asset: 'USDT' },
{ chain: 'ethereum', asset: 'USDT' },
{ chain: 'solana', asset: 'USDT' },
],
expiresAt: order.paymentExpiresAt,
metadata: { product: 'checkout' },
},
{ idempotencyKey: `payment-order:${order.id}` },
)
}On retry, pass the same merchantOrderId, amount, accepted assets, expiry, and idempotency key. Render the returned paymentInstructions rather than reconstructing addresses or token contracts in the browser. If the payer selects TRON USDT, show the TRON instruction. If they select Ethereum USDT, show the Ethereum instruction. Those are different payment paths, even when the business order and invoice amount are the same.
amount is a decimal asset-unit string. Chain events are compared in smallest units after applying token decimals, so display the exact amount from order.amount, the expiry from order.expiresAt, and the chain, asset, and address from the selected paymentInstructions entry. If you use shared addresses with automatic amount adjustment, the returned order.amount may differ from the base amount you requested; the customer must pay the returned amount exactly. See Payment Orders for allocation and matching behavior.
Match the full instruction, not a wallet balance
A USDT payment gateway or direct wallet flow becomes unreliable when it treats “wallet balance increased” as payment proof. That loses the relationship between the transfer and the order, especially when multiple chains and many customers are active at the same time.
StableOps promotes an order only when the transfer matches the open order on every relevant key:
| Match key | Why it matters |
|---|---|
| Organization and environment | Prevents test, live, and tenant records from bleeding into each other |
| Chain | TRON USDT is not Ethereum USDT; the network is part of the payment instruction |
| Asset | USDT sent to a USDC-only order is not a match |
| Receiving address | Connects the transfer to the allocated candidate |
| Exact amount | Prevents underpayments, overpayments, and shared-address ambiguity |
| Open order state | Late transfers should not silently revive an expired or canceled attempt |
Keep this tuple together in your own records:
(internal order id, StableOps payment order id, chain, asset, address, amount, expiry)Support, reconciliation, and fulfillment should refer to that tuple, not only to a transaction hash or wallet balance. A transaction hash is useful for investigation, but it is not enough to decide that a specific order is paid.
Be deliberate about address reuse
Address policy is an operating decision, not just a wallet-management detail.
| Address policy | Best fit | Reliability trade-off |
|---|---|---|
| Single-use address per order | Checkout, invoices, and one-time purchases | Clearest attribution; requires enough available addresses for each accepted chain and asset |
| Shared address with exact amounts | Fixed-price tiers and limited address pools | Requires exact unique amounts among in-flight orders on the same address |
| Shared address with automatic amount adjustment | Repeated fixed prices where tiny amount nudges are acceptable | Easier operations, but the checkout must display and enforce the returned adjusted amount |
For USDT, address formats differ by chain family. EVM addresses use 0x..., TRON addresses are base58 strings beginning with T, and Solana token accounts and wallet addresses follow Solana conventions. Store and display the address returned in paymentInstructions; do not apply a normalization rule that is correct for one chain family to another.
Address capacity also affects checkout reliability. If you use single-use pools, alert before eligible USDT addresses are exhausted. If you use shared pools, monitor same-amount collisions, expired orders, and customers who manually edit the amount.
Use confirmations as progress and finalized as the fulfillment boundary
USDT payment confirmation is not one universal block count. Each chain has its own timing, finality assumptions, and reorg risk. Your application should react to the normalized payment lifecycle instead of hard-coding a single number everywhere.
| Event | What it means | Good use | Do not use it for |
|---|---|---|---|
payment.detected | A matching USDT transfer was observed | Show “payment received, confirming” and retain the transaction hash | Shipping, granting irreversible access, or final ledger posting |
payment.confirmed | The configured confirmation level was reached | Update progress or perform carefully reversible work | Assuming every chain risk has passed |
payment.finalized | The payment reached the final fulfillment state | Fulfill idempotently and write the final receipt record | Skipping webhook verification or event deduplication |
payment.reverted | A previously observed payment no longer stands | Stop optimistic work and run compensation or review | Ignoring rare rollback paths |
payment.expired | No matching transfer arrived before the deadline | Close the attempt and ask the customer to start a new one | Treating a later transfer as automatic success |
For irreversible actions, wait for a verified and deduplicated payment.finalized webhook. The confirmation model explains the state machine, and Stablecoin Payment Confirmations gives a decision framework for product teams.
Make webhooks the durable handoff to your app
The browser can return after the customer submits a wallet transaction. A wallet can show a hash. Neither is a final payment credential. Your backend should fulfill from a signed webhook after it has durably accepted the event.
Use this boundary:
Receive StableOps webhook
|
v
Verify raw body signature
|
v
Persist unique X-Event-Id and legal order transition in one transaction
|
v
For first verified payment.finalized, enqueue irreversible fulfillment by internal order ID
|
v
Return 2xx, then let a worker fulfill idempotentlyEvent deduplication and fulfillment idempotency solve different problems. The unique event ID prevents retries and replays from applying the same event twice. Fulfillment idempotency by internal order ID prevents a product entitlement, shipment, or ledger write from happening twice even if two valid code paths race. The full webhook handling pattern is covered in Stablecoin Payment Webhooks and the Webhook guide.
Plan for wrong network, wrong amount, and expired orders
Reliable USDT collection includes a support policy for misses. Do not improvise these cases after the first customer sends funds incorrectly.
| Case | What StableOps does | What your team should do |
|---|---|---|
| Wrong chain | The transfer does not match an order that did not accept that chain | Investigate the on-chain transfer and recover or refund out of band from the wallet you control |
| Wrong asset | The order does not advance if the asset is not the accepted asset | Treat it as a support exception, not automatic payment success |
| Underpayment or overpayment | The order does not match because the amount differs in smallest units | Let the order expire or resolve manually with a top-up, refund, or credit policy |
| Late transfer after expiry | The original order remains expired | Reconcile manually; create a new order when the customer should try again |
| Address pool exhausted | A complete instruction cannot be issued for that chain and asset | Import more addresses or narrow the accepted chain/asset choices |
Because StableOps is non-custodial, you recover misdirected funds yourself from the addresses you control. StableOps can help you identify what happened, but it does not hold private keys or move funds for you. The wrong amount or chain FAQ covers the operational behavior in more detail.
Production checklist
- Choose the exact USDT chains your product will accept; do not present “USDT” without a network.
- Import and monitor enough eligible receiving addresses for each accepted chain and asset.
- Persist the business order before creating the Payment Order, and reuse the same idempotency key on retries.
- Render
order.amountandorder.expiresAttogether withchain,asset, andaddressfrom the selectedpaymentInstructionsentry. - Keep the selected instruction tuple in your own reconciliation records.
- Define support policies for wrong chain, wrong asset, underpayment, overpayment, expiry, and late transfers.
- Use
payment.detectedandpayment.confirmedfor progress; reserve irreversible fulfillment for verifiedpayment.finalized. - Verify webhooks from the raw body, store
X-Event-Idwith a unique constraint, and make fulfillment idempotent by internal order ID. - Test duplicate delivery, replay, expired orders, and a worker crash before launch.
FAQ
Can I accept USDT on multiple chains with one integration?
Yes, if those (chain, asset) pairs are supported and you have eligible receiving addresses for them. Create the Payment Order with explicit USDT pairs such as TRON, Ethereum, or Solana, then render the returned instruction for the customer's selected network.
Is a USDT payment confirmed when the customer sends a transaction hash?
No. A transaction hash is an investigation and progress signal. It is not a final receipt for a business order. Fulfill only after your backend verifies and deduplicates a payment.finalized webhook.
What happens if the customer sends USDT on the wrong chain?
The order does not advance unless the transfer matches an accepted chain, asset, address, amount, and open order. Because you control the receiving addresses, any recovery or refund happens through your own treasury or support process, outside the automatic order lifecycle.
Start with one network, then expand deliberately
The safest way to launch USDT payments is to start with one chain, one address policy, one webhook endpoint, and one idempotent payment.finalized fulfillment path. Once that loop survives duplicate delivery, expiry, and support misses in Sandbox, add more USDT networks with the same matching and reconciliation discipline.
To put this flow into practice, import receiving addresses, then use Payment Orders to create your first chain-specific USDT order in Sandbox. Before launch, turn the wrong amount or chain FAQ into a support runbook for payment exceptions.
Related articles
Build reliable crypto payment webhook handling with raw-body signature verification, event deduplication, idempotent fulfillment, and replay-safe recovery.
Accept USDC payments into merchant-controlled wallets with payment orders, chain monitoring, finality, and reliable webhook fulfillment.
Compare custodial gateways, direct wallet transfers, and non-custodial payment infrastructure for reliable stablecoin collection.