POST /apiv2/withdraw
Withdraw TRX from your Netts balance to any 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.
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-Real-IP | Yes | IP address from your whitelist |
| 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). |
| 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-Real-IP: your_whitelisted_ip" \
-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-Real-IP": "your_whitelisted_ip",
"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 + X-Real-IP (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 whatever address the request specifies. Same minimum, same fee (1 TRX), 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 + X-Real-IP.
// 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" } }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 |
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). - 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 anyaddressit 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.