S
docs.syncra.money
Finance

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/withdraw

Request parameters (JSON Body)

FieldTypeRequiredDescription
amount_usdtint64YesThe 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_addressstringYesThe 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_keystringNoThe 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"
}
FieldTypeDescription
statusstringThe synchronous verdict: COMPLETED | PENDING_MANUAL.
transaction_idstringThe on-chain TX hash (present only with COMPLETED; empty on the manual path).
amount_usdtint64Echo of the request amount (whole USDT, as a string).
fee_usdt_centsint64The withdrawal fee in USDT cents: the amount actually charged (automatic path) or the amount the approval path will charge.
net_usdt_centsint64The 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

StatusDescription
COMPLETEDAuto-approval: the on-chain transaction has been broadcast (transaction_id = TX hash), the funds are debited, and the ledger entry is posted.
PENDING_MANUALThe 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"):

StatusDescriptionTransition
PENDINGThe request is created (manual path), the funds are reserved, and it awaits operator approval.→ APPROVED or CANCELLED.
APPROVEDApproved, the transaction is broadcast to the blockchain, the ledger is debited.Terminal.
CANCELLEDRejected 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=100000

A 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)

ParameterTypeRequiredDescription
amount_usdt_centsint64NoThe 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"
}
FieldTypeDescription
base_fee_usdt_centsint64The tenant base (p2p_system_config.withdrawal_fee_usdt_cents).
override_appliedbooltrue = a per-merchant override replaced the base.
total_fee_usdt_centsint64The fee actually applied: the override when present, otherwise the base.
net_amount_usdt_centsint64amount − 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/deposits

The 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": ""
}
FieldTypeDescription
block_timestampstring(RFC3339)The on-chain block time plus finality depth (19 confirmations).
amount_usdt_centsint64The credited amount, in USDT cents.
tx_hashstringThe on-chain TRON hash.
trc20_addressstringThe deposit address (reconstructed from the counterparty's address history).
statusstringCREDITED (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/withdrawals

The 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": ""
}
FieldTypeDescription
created_atstring(RFC3339)The request time (manual queue) or the ledger debit time (automatic path).
amount_usdt_centsint64The requested gross amount, in USDT cents.
fee_usdt_centsint64The fee, in USDT cents (0 for history predating the fee mechanism).
net_usdt_centsint64The net on-chain amount = gross − fee.
tx_hashstringThe on-chain hash; empty until the withdrawal is broadcast.
statusstringPENDING (awaiting approval) / APPROVED (approved, not broadcast yet) / COMPLETED (broadcast done) / CANCELLED (rejected by the operator). FAILED is reserved and never persisted.
tron_addressstringThe destination address.

Webhook events

EventRequest statusDescription
p2p.finance.withdrawal_completedAPPROVEDThe USDT withdrawal is complete (the on-chain tx is broadcast)
p2p.finance.withdrawal_failedCANCELLEDThe 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:

MethodEndpointPurpose
GET/wallet/deposit-addressRead-only: the current active address.
POST/wallet/deposit-addressInitial generation or rotation of the address.

Get the current address

GET /api/v1/p2p/merchant/wallet/deposit-address

Returns 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-address

Generates 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-address and 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).


  • 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_BALANCE and other errors.

On this page