POST /apiv2/withdraw
Withdraw TRX from your Netts balance to a TRON address. The request returns an order number immediately; the actual on-chain payout is performed asynchronously by the backend (within ~5 minutes). Track the result by polling the status endpoint or by configuring a webhook.
ℹ️ How it works. Placing a withdrawal reserves the amount from your balance right away (balance is debited the moment the order is accepted). A backend daemon then sends the TRX and marks the order
completedorfailed. There is no synchronous on-chain result in the initial response — you always get apendingacknowledgement first.Before the order is accepted, the destination address is checked. If withdrawals to that address are not available, or the check cannot be completed at the moment, the request is rejected: no order is created and nothing is reserved from your balance.
Endpoint URL
POST https://netts.io/apiv2/withdrawRequest Headers
| Header | Required | Description |
|---|---|---|
| Content-Type | Yes | application/json |
| X-API-KEY | Yes | Your API key from the Netts dashboard |
| X-Idempotency-Key | No | Optional client-generated key (base64) to safely retry without a double withdrawal. If omitted, the server derives one automatically. This value becomes your orderId. |
Request Body
{
"amount": 15,
"address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| amount | number | Yes | Gross amount in TRX (minimum 3). The fee is deducted from this amount — the recipient gets amount − fee (net). |
| address | string | Yes | Destination TRON address (T…, 34 chars, base58). Checked before the order is accepted. |
| sub_and_robot_out | boolean | No | Robot/sub payout mode: applies the 2 TRX fee instead of 1 TRX. Default false. |
Fee. A flat fee is withheld from the gross
amount: 1 TRX normally, or 2 TRX whensub_and_robot_out = true. The order is rejected ifamount − fee ≤ 0.
Example Requests
The examples below also build and send the
X-Idempotency-Keyso an accidental repeat does not create a second withdrawal. See Idempotency for the full rules.
cURL
API_KEY="your_api_key"
ADDR="TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
AMOUNT=15
NONCE=$(( $(date +%s) / 2 )) # stable for retries within a 2s window; or your own order UUID
# X-Idempotency-Key = base64url( HMAC-SHA256( API_KEY, "addr:amount:nonce" ) )
IDEMP=$(printf '%s' "${ADDR}:${AMOUNT}:${NONCE}" \
| openssl dgst -sha256 -hmac "$API_KEY" -binary | basenc --base64url | tr -d '=')
curl -X POST https://netts.io/apiv2/withdraw \
-H "Content-Type: application/json" \
-H "X-API-KEY: $API_KEY" \
-H "X-Idempotency-Key: $IDEMP" \
-d "{\"amount\": $AMOUNT, \"address\": \"$ADDR\"}"Python
import time, hmac, hashlib, base64, requests
API_KEY = "your_api_key"
url = "https://netts.io/apiv2/withdraw"
payload = {"amount": 15, "address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
# X-Idempotency-Key = base64url( HMAC-SHA256( API_KEY, "addr:amount:nonce" ) ), padding stripped.
# Generate ONCE per order and resend the same value on every retry.
nonce = str(int(time.time() // 2)) # 2s bucket; or your own order UUID
message = f"{payload['address']}:{payload['amount']}:{nonce}"
idem_key = base64.urlsafe_b64encode(
hmac.new(API_KEY.encode(), message.encode(), hashlib.sha256).digest()
).decode().rstrip("=")
headers = {
"Content-Type": "application/json",
"X-API-KEY": API_KEY,
"X-Idempotency-Key": idem_key,
}
resp = requests.post(url, headers=headers, json=payload)
detail = resp.json().get("detail", {})
if resp.status_code == 202 and detail.get("status") == "pending":
d = detail["data"]
print(f"Order ID: {d['orderId']}") # use it for the status endpoint / webhook
print(f"Net to recipient: {d['net']} TRX (fee {d['fee']})")
else:
print(f"Code {detail.get('code')}: {detail.get('msg', detail)}")Response
Accepted — withdrawal queued (202 Accepted)
The amount is reserved from your balance and the payout is scheduled. Poll the status endpoint (or wait for the webhook) until it becomes completed / failed.
{
"detail": {
"code": 10000,
"status": "pending",
"msg": "Withdrawal request accepted, processing within 5 minutes.",
"data": {
"orderId": "EXAMPLEorderId0000000000000000000000000000Aa",
"amount": 15.0,
"fee": 1.0,
"net": 14.0,
"address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}Response Fields
| Field | Type | Description |
|---|---|---|
| detail.code | integer | 10000 accepted |
| detail.status | string | pending |
| detail.data.orderId | string | Order number — a 43-char URL-safe string. Use it for the status endpoint and it identifies the order in webhook payloads. |
| detail.data.amount | number | Gross amount requested (TRX) |
| detail.data.fee | number | Fee withheld (1 or 2 TRX) |
| detail.data.net | number | Amount the recipient receives (amount − fee) |
| detail.data.address | string | Destination address |
Status Endpoint
GET https://netts.io/apiv2/withdraw/status/{orderId}Headers: X-API-KEY (the order must belong to the authenticated user). orderId is URL-safe — pass it as-is, no URL-encoding needed.
| Order state | HTTP | code | status |
|---|---|---|---|
| Completed (TRX sent) | 200 | 10000 | completed (with processed_at) |
| Queued / sending | 200 | 10001 | pending |
| Failed | 200 | 5003 | failed (with error_message) |
| Not found / not yours | 404 | -1 | — |
{
"detail": {
"code": 10000,
"status": "completed",
"data": {
"orderId": "EXAMPLEorderId0000000000000000000000000000Aa",
"amount": 15.0, "fee": 1.0, "net": 14.0,
"address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"processed_at": "2026-01-01 00:00:00+00:00"
}
}
}Sub-user accounts
Sub-user withdrawals work exactly the same as for regular users — just with the sub-user's own API key. A sub-user calls this same POST /apiv2/withdraw endpoint, authenticated with its own key; the withdrawal is debited from that sub-user's own balance and sent to the address the request specifies. Same minimum, same fee (1 TRX), same address check, same flow. There is no separate sub-user endpoint — each account, parent or sub-user, only ever withdraws its own balance with its own key.
Webhooks
Instead of polling, configure a webhook once and Netts will POST a signed notification when each of your withdrawals reaches a terminal state (completed / failed). The webhook is stored per user and applies to that account's withdrawals. If no webhook is configured, just poll the status endpoint.
Configure / view / remove
POST https://netts.io/apiv2/withdraw/webhook # create or update
GET https://netts.io/apiv2/withdraw/webhook # view current config (secret is never returned)
DELETE https://netts.io/apiv2/withdraw/webhook # unsubscribeHeaders: X-API-KEY.
// POST body
{
"callback_url": "https://your-server.example/netts/withdraw-hook",
"secret": "your_shared_secret_min_8_chars",
"enabled": true
}| Parameter | Type | Required | Description |
|---|---|---|---|
| callback_url | string | Yes | http(s) URL (≤ 2048 chars) that receives the POST |
| secret | string | Yes | Shared secret (8…256 chars) used to sign each payload |
| enabled | boolean | No | Turn delivery on/off without deleting the config. Default true |
GET returns { callback_url, enabled, secret_set, updated_at } — the secret itself is never echoed back.
Delivery payload
Netts sends a POST to your callback_url with header X-Netts-Signature: base64( HMAC-SHA256( secret, raw_body ) ) and this JSON body:
{
"orderId": "EXAMPLEorderId0000000000000000000000000000Aa",
"status": "completed",
"amount": 15.0,
"fee": 1.0,
"net": 14.0,
"address": "TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"processed_at": "2026-01-01 00:00:00+00:00",
"error_message": null
}statusiscompletedorfailed(onfailed,error_messageis populated).
Verifying the signature
The signature is computed over the canonical JSON of the body: keys sorted, no spaces (separators=(",", ":")). Recompute it the same way and compare.
import hmac, hashlib, base64, json
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = base64.b64encode(
hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, signature_header)
# Flask example: verify against the EXACT bytes received, then parse.
# if verify(request.get_data(), request.headers["X-Netts-Signature"], SECRET): ...Always verify against the raw received bytes. If you re-serialize the parsed JSON, reproduce the canonical form:
json.dumps(payload, ensure_ascii=False, separators=(",",":"), sort_keys=True).
Delivery guarantees
- Respond with HTTP 2xx to acknowledge. Any other response (or a timeout) is treated as a failed attempt.
- Up to 3 attempts per order, within a 21-minute window from the moment the order was created (retry backoff ≈ 5 minutes). After that, delivery is given up — fall back to the status endpoint.
- Deliveries are de-duplicated: each order is delivered at most once successfully.
- Make your handler idempotent on
orderId.
Error Responses
Authentication Error (401)
{ "detail": { "code": -1, "msg": "Invalid API key or IP not in whitelist" } }Insufficient Balance (403)
{ "detail": { "code": 1004, "status": "failed", "msg": "Insufficient balance: 2.0 < 15 TRX" } }Address Not Available (403)
Withdrawals to this destination address are not accepted. No order is created and nothing is reserved. Use a different address or contact support.
{ "detail": { "code": 1006, "status": "failed", "msg": "Withdrawals to this address are not available. Contact support." } }Address Check Unavailable (503)
The destination address could not be checked right now. No order is created and nothing is reserved. Retry shortly — the same X-Idempotency-Key may be reused.
{ "detail": { "code": 1009, "status": "failed", "msg": "Address check is temporarily unavailable. No request was created. Retry shortly." } }Pending Withdrawal Exists (409)
You may have only one pending withdrawal at a time on your own balance. Wait until the current one is processed.
{ "detail": { "code": 4090, "status": "failed", "msg": "You have a pending withdrawal. Wait until it is processed." } }Validation Error (400)
{ "detail": { "code": 5004, "status": "failed", "msg": "Minimum withdrawal is 3 TRX" } }Error Code Reference
| Code | Description | HTTP Status |
|---|---|---|
10000 | Accepted (withdrawal queued) / Completed (status endpoint) | 202 / 200 |
10001 | Pending — queued or sending (status endpoint) | 200 |
208 | Duplicate of an already-accepted request — cached response | 208 |
- | Same request still being processed (do not retry yet) | 409 |
4090 | You already have a pending withdrawal | 409 |
-1 | Invalid API key / IP not in whitelist, or order not found | 401 / 404 |
1004 | Insufficient balance | 403 |
1006 | Withdrawals to this address are not available | 403 |
1009 | Address check temporarily unavailable — no order created, retry shortly | 503 |
5004 | Validation error (amount < 3, fee ≥ amount, bad address, bad idempotency key) | 400 |
5003 | Withdrawal failed / service unavailable | 200 (status) / 503 |
5000 | Internal server error | 500 |
Rate Limits
Limited per API key (header X-API-KEY):
| Period | Limit |
|---|---|
| 1 second | 5 requests |
| 1 minute | 150 requests |
Rate Limit Exceeded (429)
{ "message": "API rate limit exceeded" }Idempotency
Send the optional X-Idempotency-Key header so an accidental repeat does not create a second withdrawal — the original response is returned with HTTP 208. If you do not send the header, the server derives a key automatically from your request parameters within a short time window. The key is also your orderId.
How to form the key
The key is base64url( HMAC-SHA256( secret, message ) ) with = padding removed — a 43-char URL-safe string, where:
- secret = your API key (
X-API-KEY); - message = the fields joined with
:—address:amount:nonce.
nonce is any value that is stable across retries of the same logical order but different between distinct orders — e.g. a UUID you keep for that order, or a coarse timestamp bucket. Generate the key once per order and resend the exact same value on every retry.
import hmac, hashlib, base64, time
def make_idempotency_key(api_key, address, amount, nonce=None):
if nonce is None:
nonce = str(int(time.time() // 2)) # 2-second bucket; or your own order UUID
message = f"{address}:{amount}:{nonce}"
digest = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=") # 43-char URL-safeValidation. A supplied
X-Idempotency-Keymust be 16–64 characters from the charsetA–Z a–z 0–9 + / = _ -. A malformed or over-long key is rejected with HTTP 400 (code 5004).
| Status Code | Meaning |
|---|---|
| 202 | Accepted (first request) |
| 208 | Already accepted — cached response returned (no second withdrawal) |
| 409 | The same request is currently being processed — wait, do not retry yet |
Retrying after a failure. Only accepted results are cached. If the previous attempt failed (e.g. insufficient balance, validation), you may safely retry with the same key — the request is attempted again rather than returning the old error. While an attempt is still in progress you get
409; wait and retry.
Notes
- Asynchronous payout. The response is always a
pendingacknowledgement; the TRX is sent by a backend daemon, typically within ~5 minutes. Use the status endpoint or a webhook for the result. - Balance is reserved immediately when the order is accepted (not when the TRX is finally sent).
- Minimum: 3 TRX. Fee: 1 TRX (or 2 TRX with
sub_and_robot_out), withheld from the grossamount; the recipient receivesnet = amount − fee. - One pending at a time on your own balance (
code 4090). - Destination address check. Every request is checked before the order is accepted:
code 1006(HTTP 403) — withdrawals to this address are not available;code 1009(HTTP 503) — the check is temporarily unavailable, retry shortly. In both cases no order is created. - Sub-users withdraw exactly like regular users — same
POST /apiv2/withdrawendpoint, same rules, but authenticated with the sub-user's own API key. A sub-user withdraws its own balance to theaddressit specifies. There is no separate sub-user endpoint. - orderId is a 43-char URL-safe string; pass it as-is in the status URL (no encoding required).
- Webhooks: per-user, signed with
X-Netts-Signature; up to 3 attempts within a 21-minute window. Configure viaPOST /apiv2/withdraw/webhook.