Wallet: USDT withdrawals and the deposit address
Withdrawing your balance in USDT over the TRON network, and the TRC-20 deposit address for topping up the merchant's collateral balance
Crypto withdrawals (Wallet)
The Syncra V2 platform lets merchants convert their accumulated fiat balance and withdraw it to their own external crypto addresses in the Tether (USDT) stablecoin.
Withdrawals run over the TRON (TRC-20) blockchain network — the fastest
and cheapest method. The recipient address is always passed in the
tron_address field.
The BEP-20 (BNB Smart Chain) and ERC-20 (Ethereum) networks are
under development and will be enabled in future updates. Do not pass
blockchain: "TRC20" — it is not a valid network identifier. The
protocol's blockchain field uses network codes (TRON, TON); for USDT
withdrawals, only TRON is currently supported (the default behavior).
Create a withdrawal request
To initiate a crypto withdrawal from your balance, send a POST request:
POST /api/v1/p2p/merchant/wallet/withdrawRequest parameters (JSON Body)
| Field | Type | Required | Description |
|---|---|---|---|
amount_usdt | int64 | Yes | The withdrawal amount in USDT, an integer (whole USDT units, not a float). For example, 1000 = 1,000 USDT. Internally converted to cents (amount_usdt * 100) and stored as an int64 in minor units. |
tron_address | string | Yes | The recipient's TRC-20 (Tron) address (base58check, 34 characters; an invalid address → 400). Do not pass a separate blockchain: "TRC20" field — the withdrawal network is fixed to TRON. |
idempotency_key | string | No | The field exists in the contract but is not validated and not required: the platform itself guarantees withdrawal idempotency — the deterministic withdrawal key is built from (merchant, address, amount, current fee), so repeating an identical request converges on the same on-chain transaction and never produces a second transfer. You may pass it for symmetry with PayIn/PayOut — the value is ignored. |
Request example
{
"amount_usdt": 1000,
"tron_address": "TYHCiW3XN5sP9cWJ5T4Dfs9Y18h46t9w1a",
"idempotency_key": "f3b8a1c2-4d5e-4f6a-9b0c-1d2e3f4a5b6c"
}Response example (200 OK, auto-approval)
The response fields are top-level, with no data wrapper. In the synchronous
response, status is either COMPLETED (the automatic path: the
transaction has been broadcast to the blockchain and transaction_id carries
the on-chain hash) or PENDING_MANUAL (the manual path: the request is
queued for operator approval). This is the synchronous verdict for the
request; for the domain status of the request itself (in listings and
webhooks), see Withdrawal request
statuses:
{
"status": "COMPLETED",
"transaction_id": "0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c3d6e7f8a9b...",
"amount_usdt": "1000",
"tron_address": "TYHCiW3XN5sP9cWJ5T4Dfs9Y18h46t9w1a",
"created_at": "2026-07-12T17:22:00Z",
"fee_usdt_cents": "150",
"net_usdt_cents": "99985"
}Response example (200 OK, manual approval)
An amount above the effective auto-withdrawal limit (or the MANUAL_ONLY
policy) creates a request in the operator queue: status: "PENDING_MANUAL", transaction_id is empty, and the fee shown is the one
the approval path will charge:
{
"status": "PENDING_MANUAL",
"transaction_id": "",
"amount_usdt": "50000",
"tron_address": "TYHCiW3XN5sP9cWJ5T4Dfs9Y18h46t9w1a",
"created_at": "2026-07-12T17:23:00Z",
"fee_usdt_cents": "150",
"net_usdt_cents": "4999985"
}| Field | Type | Description |
|---|---|---|
status | string | The synchronous verdict: COMPLETED | PENDING_MANUAL. |
transaction_id | string | The on-chain TX hash (present only with COMPLETED; empty on the manual path). |
amount_usdt | int64 | Echo of the request amount (whole USDT, as a string). |
fee_usdt_cents | int64 | The withdrawal fee in USDT cents: the amount actually charged (automatic path) or the amount the approval path will charge. |
net_usdt_cents | int64 | The net amount credited to the recipient: amount_usdt*100 − fee_usdt_cents. |
Amounts within the effective auto-approval limit (a per-merchant override
or the tenant base; see the fee
calculation) are processed
instantly and return COMPLETED. Amounts above the limit (or under the
MANUAL_ONLY policy) go to PENDING_MANUAL and require administrator
approval.
Withdrawal request statuses
A USDT withdrawal has two status surfaces — do not mix them up:
1. Status in the synchronous response of POST /wallet/withdraw
| Status | Description |
|---|---|
COMPLETED | Auto-approval: the on-chain transaction has been broadcast (transaction_id = TX hash), the funds are debited, and the ledger entry is posted. |
PENDING_MANUAL | The request is in the operator's manual approval queue (the MANUAL_ONLY policy or an amount above the effective limit). transaction_id is empty. |
Terminal failures never appear in this field — withdrawal errors are returned
as RFC 9457 problem+json (for example, 400 for an invalid TRON address or
400 FAILED_PRECONDITION for insufficient balance).
2. Domain status of the request (domain.WithdrawalStatus)
It lives in the request row (p2p_pending_withdrawals) and is visible in the
withdrawal history and in webhooks
(p2p.finance.withdrawal_completed carries status: "APPROVED"):
| Status | Description | Transition |
|---|---|---|
PENDING | The request is created (manual path), the funds are reserved, and it awaits operator approval. | → APPROVED or CANCELLED. |
APPROVED | Approved, the transaction is broadcast to the blockchain, the ledger is debited. | Terminal. |
CANCELLED | Rejected by the operator or cancelled before broadcast; the reserve returns to the balance. | Terminal. |
The V1 Compatibility Adapter automatically maps APPROVED → COMPLETED
for legacy integrations. PROCESSING is reserved for the future (block
confirmations between APPROVED and the final state) and is not emitted by
the current code. The withdrawal listing
additionally distinguishes PENDING (awaiting approval) from COMPLETED
(broadcast done, TX hash known).
Withdrawal fee calculation (before submit)
GET /api/v1/p2p/merchant/wallet/withdraw-fee?amount_usdt_cents=100000A read-only fee calculator to run before you submit the request: it resolves the same per-counterparty withdrawal policy as the money path (a per-merchant override, otherwise the tenant base), so the quote can never diverge from the fee actually charged. No funds move.
Parameters (Query)
| Parameter | Type | Required | Description |
|---|---|---|---|
amount_usdt_cents | int64 | No | The planned withdrawal amount in USDT cents (note: not whole USDT, unlike the amount_usdt at creation). When set (> 0), the response additionally carries net_amount_usdt_cents. |
Response example (200 OK)
{
"base_fee_usdt_cents": "150",
"override_applied": false,
"total_fee_usdt_cents": "150",
"net_amount_usdt_cents": "99850"
}| Field | Type | Description |
|---|---|---|
base_fee_usdt_cents | int64 | The tenant base (p2p_system_config.withdrawal_fee_usdt_cents). |
override_applied | bool | true = a per-merchant override replaced the base. |
total_fee_usdt_cents | int64 | The fee actually applied: the override when present, otherwise the base. |
net_amount_usdt_cents | int64 | amount − total_fee (0 when the amount is not passed). May be ≤ 0 when the fee covers the entire amount — the money path rejects such a withdrawal (the fee must be smaller than the amount). |
Deposit history (TRC-20 incoming)
GET /api/v1/p2p/merchant/wallet/depositsThe merchant's incoming TRC-20 deposits with custodial statuses: a ledger
credit plus the life cycle of the receiving address's sweep worker. Keyset
pagination per AIP-158 (page_size 1–100, default 20;
page_token/next_page_token).
Response example (200 OK)
{
"data": [
{
"block_timestamp": "2026-07-12T17:20:01Z",
"amount_usdt_cents": "100000",
"tx_hash": "0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c3d6e7f8a9b...",
"trc20_address": "TCBYf3t6D2VshYVQLh55GGBDWTZvjQRoHd",
"status": "SWEPT"
}
],
"next_page_token": ""
}| Field | Type | Description |
|---|---|---|
block_timestamp | string(RFC3339) | The on-chain block time plus finality depth (19 confirmations). |
amount_usdt_cents | int64 | The credited amount, in USDT cents. |
tx_hash | string | The on-chain TRON hash. |
trc20_address | string | The deposit address (reconstructed from the counterparty's address history). |
status | string | CREDITED (the funds remain on the deposit address) / SWEEP_PENDING (the sweep has not run yet or is in flight) / SWEEP_FAILED (the last sweep failed, retry pending) / SWEEP_STUCK (the broadcast outcome is unknown, operator attention) / SWEPT (the sweep completed, the funds are on the hot wallet). |
Withdrawal history
GET /api/v1/p2p/merchant/wallet/withdrawalsThe withdrawal history: the manual approval queue merged with posted ledger debits, deduplicated by the on-chain TX hash. The same AIP-158 pagination as for deposits.
Response example (200 OK)
{
"data": [
{
"created_at": "2026-07-12T17:22:00Z",
"amount_usdt_cents": "100000",
"fee_usdt_cents": "150",
"net_usdt_cents": "99850",
"tx_hash": "0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c3d6e7f8a9b...",
"status": "COMPLETED",
"tron_address": "TYHCiW3XN5sP9cWJ5T4Dfs9Y18h46t9w1a"
}
],
"next_page_token": ""
}| Field | Type | Description |
|---|---|---|
created_at | string(RFC3339) | The request time (manual queue) or the ledger debit time (automatic path). |
amount_usdt_cents | int64 | The requested gross amount, in USDT cents. |
fee_usdt_cents | int64 | The fee, in USDT cents (0 for history predating the fee mechanism). |
net_usdt_cents | int64 | The net on-chain amount = gross − fee. |
tx_hash | string | The on-chain hash; empty until the withdrawal is broadcast. |
status | string | PENDING (awaiting approval) / APPROVED (approved, not broadcast yet) / COMPLETED (broadcast done) / CANCELLED (rejected by the operator). FAILED is reserved and never persisted. |
tron_address | string | The destination address. |
Webhook events
| Event | Request status | Description |
|---|---|---|
p2p.finance.withdrawal_completed | APPROVED | The USDT withdrawal is complete (the on-chain tx is broadcast) |
p2p.finance.withdrawal_failed | CANCELLED | The withdrawal was rejected by the operator or cancelled before broadcast; the reserve is back on the balance |
Webhook on withdrawal completion
When a request reaches terminal success (APPROVED), Syncra sends an event
to your webhook URL (a flat CallbackEventPayload with no nested data —
see Callbacks):
{
"event": "p2p.finance.withdrawal_completed",
"deal_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"merchant_id": "8f870a31-b88d-4cb0-a882-628d08cbda88",
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 100000,
"currency": "USDT",
"status": "APPROVED",
"timestamp": "2026-07-12T17:22:30Z"
}The webhook signature is passed in the X-Signature header (see
Callbacks). For more on webhook handling and
signature verification, see Callbacks → Request
signature.
TRC-20 deposit address
Besides withdrawals, the merchant wallet has a TRC-20 address for depositing USDT collateral into the merchant's operating balance. Deposits made to this address cover payouts (PayOut), crypto withdrawals, and platform fees.
The deposit address belongs to the merchant and tops up their own operating balance. Clients do not pay to this address: in PayIn deals, clients transfer to trader payment details (card/SBP/IBAN) issued on the payment page (see PayIn).
The address is managed with two operations:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /wallet/deposit-address | Read-only: the current active address. |
| POST | /wallet/deposit-address | Initial generation or rotation of the address. |
Get the current address
GET /api/v1/p2p/merchant/wallet/deposit-addressReturns the merchant's current active TRC-20 address. The tenant_id /
merchant_id fields in the request are advisory (with HMAC
authentication, the real scope comes from the signed token — claims win; see
Error codes).
Response example (200 OK)
Along with the address, updated_at is returned — the time of the last
generation or rotation of the address (RFC3339):
{
"trc20_address": "TYHCiW3XN5sP9cWJ5T4Dfs9Y18h46t9w1a",
"updated_at": "2026-08-23T18:58:54Z"
}When the address has not been generated yet (404 Not Found)
The standard RFC 9457 wrapper (detail holds the nested gRPC details as
JSON):
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "{\"code\":5,\"message\":\"deposit address not provisioned yet — POST /api/v1/p2p/merchant/wallet/deposit-address to generate one\",\"details\":[]}",
"instance": "/api/v1/p2p-engine"
}Generate and rotate the address
POST /api/v1/p2p/merchant/wallet/deposit-addressGenerates the address (first call) or rotates it (subsequent calls): the platform atomically reserves the next BIP44 derivation index, derives the address through tron-wallet-api, and stores it in the merchant's row. The request body is optional — the scope resolves from the authentication context.
Response example (200 OK)
{
"trc20_address": "TYHCiW3XN5sP9cWJ5T4Dfs9Y18h46t9w1a",
"wallet_derivation_index": 17
}What happens to the old address on rotation
- The new address immediately replaces the old one in the merchant's
row:
GET /wallet/deposit-addressand all subsequent deposits point to the new address. - Every previously issued address stays forever in the platform's permanent
block-polling set (
p2p_counterparty_addresses): USDT sent to the old address after rotation is not lost — it keeps being tracked and credited to the merchant's balance. - After a rotation, update the address you have stored (verify it via
GET /wallet/deposit-address).
There are two POST paths for generation. The canonical path for a
merchant server-to-server integration is
POST /api/v1/p2p/merchant/wallet/deposit-address (no merchant_id in
the path; the scope comes from the token). The legacy path
POST /api/v1/p2p/merchants/{merchant_id}/deposit-address remains
functional and is used for admin impersonation (a platform operator
acting on behalf of the merchant); with HMAC authentication, the
merchant_id in the path must match the token claims, and with a merchant
JWT it must match the merchant's own ID only (IDOR protection).
Related materials
- Balance — the merchant's multi-currency balances.
- Idempotency — how the platform guarantees withdrawal idempotency.
- Callbacks — webhook notifications, including
p2p.finance.withdrawal_completed. - Error codes — handling
INSUFFICIENT_BALANCEand other errors.