Withdrawals
Authorize managed wallet withdrawals with email OTP or an EIP-712 wallet signature.
Unified API selects the authorization mode from the authenticated account. Clients cannot choose or override it. Fetch the policy before preparing funds or requesting proof.
| Login type | Destination | Proof | Validity |
|---|---|---|---|
| Entered for this withdrawal | Six-digit email OTP | 10 minutes (600 seconds) | |
| Wallet | Locked login wallet | EIP-712 signature | 5 minutes (300 seconds) |
Authorization must succeed before irreversible funds preparation. Every enabled-mode request names an explicit source, uses Arbitrum native USDC at 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 as the destination asset, and carries one account-scoped UUID in the Idempotency-Key header.
Call GET /wallet/withdraw/sources or the MCP tool wallet.withdrawal_sources first. It returns arbitrum, hyperliquid, polymarket, and lighter; only status=available entries are executable. Polymarket and Lighter are coming soon. Arbitrum debits only held_usd through a sponsored Privy transaction on eip155:42161; sponsorship failure never falls back to an unsponsored send. Hyperliquid debits only perp_withdrawable_usd. Balances are never combined, inferred, or routed to another source.
1. Read the server-selected policy#
GET/wallet/withdraw/authorization#
curl https://api.superior.trade/wallet/withdraw/authorization \ -H "x-api-key: st_live_..."
wget -qO- \ --header='x-api-key: st_live_...' \ https://api.superior.trade/wallet/withdraw/authorization
$response = Invoke-RestMethod `
-Method GET `
-Uri "https://api.superior.trade/wallet/withdraw/authorization" `
-Headers @{ "x-api-key" = "st_live_..." }
$response | ConvertTo-JsonEmail-login policy (the destination is entered per request):
{"account_id":"acct_test","mode":"email_otp","masked_email":"t***r@example.com","destination_mode":"user_entered","authorization_ttl_seconds":600}
Wallet-login policy (do not send destination_wallet in wallet mode):
{"account_id":"acct_test","mode":"wallet_signature","destination_mode":"locked","destination_wallet":"0x1111111111111111111111111111111111111111","authorization_ttl_seconds":300}
Both policies return the authenticated account_id. Use this exact value as accountId in the wallet typed data; do not infer it from a browser profile or wallet address.
2A. Email OTP proof#
POST/wallet/withdraw/otp#
The destination is entered for this withdrawal; it is not loaded from a saved destination. The challenge binds account, source, canonical amount, destination, chain, asset, and idempotency key.
curl -X POST https://api.superior.trade/wallet/withdraw/otp \
-H "x-api-key: st_live_..." \
-H "content-type: application/json" \
-H "Idempotency-Key: 00000000-0000-4000-8000-000000000010" \
-d '{"amount_usd":"100","destination_wallet":"0x2222222222222222222222222222222222222222","chain":"arbitrum","asset_address":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831"}'
wget -qO- \
--method=POST \
--header='x-api-key: st_live_...' \
--header='content-type: application/json' \
--header='Idempotency-Key: 00000000-0000-4000-8000-000000000010' \
--body-data='{"amount_usd":"100","destination_wallet":"0x2222222222222222222222222222222222222222","chain":"arbitrum","asset_address":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831"}' \
https://api.superior.trade/wallet/withdraw/otp$response = Invoke-RestMethod `
-Method POST `
-Uri "https://api.superior.trade/wallet/withdraw/otp" `
-Headers @{ "x-api-key" = "st_live_..."; "content-type" = "application/json"; "Idempotency-Key" = "00000000-0000-4000-8000-000000000010" } `
-Body '{"amount_usd":"100","destination_wallet":"0x2222222222222222222222222222222222222222","chain":"arbitrum","asset_address":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831"}'
$response | ConvertTo-Json{"challenge_id":"00000000-0000-4000-8000-000000000001","masked_email":"t***r@example.com","expires_at":"2026-09-13T08:10:00Z","resend_available_at":"2026-09-13T08:01:00Z"}
The six-digit code expires 10 minutes after issuance, including after successful preflight, and permits five attempts. Resending after the 60-second cooldown rotates the code and expiry; at most five messages may be sent in six hours. Changing any bound field requires a new challenge.
Save this complete body as withdrawal.json and use it unchanged for preflight and final submission:
{
"source": "arbitrum",
"amount_usd": "100",
"destination_wallet": "0x2222222222222222222222222222222222222222",
"chain": "arbitrum",
"asset_address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"authorization": {"type":"email_otp","challenge_id":"00000000-0000-4000-8000-000000000001","code":"123456"}
}
2B. Wallet EIP-712 proof#
Generate a UUID nonce, choose an expires_at five minutes in the future, and sign this EIP-712 data with the locked login wallet. The server accepts only a future expiry no more than 10 minutes ahead of its clock.
{
"domain": {"name":"Superior Trade","version":"2","chainId":42161,"verifyingContract":"0x0000000000000000000000000000000000000000"},
"primaryType": "WithdrawalAuthorization",
"types": {"WithdrawalAuthorization":[
{"name":"accountId","type":"string"},
{"name":"source","type":"string"},
{"name":"amountUsd","type":"string"},
{"name":"destinationWallet","type":"address"},
{"name":"assetAddress","type":"address"},
{"name":"chain","type":"string"},
{"name":"idempotencyKey","type":"string"},
{"name":"nonce","type":"string"},
{"name":"expiresAt","type":"string"}
]},
"message": {"accountId":"acct_01j9ab...","source":"hyperliquid","amountUsd":"100","destinationWallet":"0x1111111111111111111111111111111111111111","assetAddress":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","chain":"arbitrum","idempotencyKey":"00000000-0000-4000-8000-000000000010","nonce":"00000000-0000-4000-8000-000000000020","expiresAt":"2026-09-13T08:05:00Z"}
}
Save this complete body as withdrawal.json. Wallet mode forbids top-level destination_wallet; the server resolves it from login identity.
{
"source": "hyperliquid",
"amount_usd": "100",
"chain": "arbitrum",
"asset_address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"authorization": {"type":"wallet_signature","signature":"0xf7833a7b22fab4150320c4caa699dec979966da6d61afae2b936dc48a6e658ac103f9b8f72d8d16dde239b75fdd47296b47d0437524841d514fb8442f8f84c7b00","nonce":"00000000-0000-4000-8000-000000000020","expires_at":"2026-09-13T08:05:00Z"}
}
3. Preflight, then submit the identical request#
Before preflight or funds preparation, recover any prior attempt by sending the complete body to POST /wallet/withdraw with the original Idempotency-Key and Idempotency-Replay-Only: true. This lookup never authorizes, creates a withdrawal, adopts a processing lease, or moves funds.
202: use the existing withdrawal immediately, even after proof expiry or balance depletion.404 withdrawal_replay_not_found: only this response allows the new-attempt preflight and preparation flow below.409 withdrawal_replay_requires_resume: the matching withdrawal has a processing claim. Send the identical normal final request directly, without the replay-only header, preflight, or preparation; the server resumes the claim without requiring fresh proof.- Other errors stop the attempt. Changed bound intent returns
409 idempotency_key_conflict.
Replay compares the canonical intent and its non-secret authorization binding. OTP codes and signature bytes are neither persisted nor hashed for replay. A different proof for an already accepted matching intent can only retrieve or resume that same withdrawal; before acceptance the usual proof verification still applies.
POST/wallet/withdraw/authorize#
Preflight verifies the intent and proof without moving funds:
curl -X POST https://api.superior.trade/wallet/withdraw/authorize \ -H "x-api-key: st_live_..." -H "content-type: application/json" \ -H "Idempotency-Key: 00000000-0000-4000-8000-000000000010" \ -d @withdrawal.json
wget -qO- \ --method=POST \ --header='x-api-key: st_live_...' \ --header='content-type: application/json' \ --header='Idempotency-Key: 00000000-0000-4000-8000-000000000010' \ --body-file=withdrawal.json \ https://api.superior.trade/wallet/withdraw/authorize
$response = Invoke-RestMethod `
-Method POST `
-Uri "https://api.superior.trade/wallet/withdraw/authorize" `
-Headers @{ "x-api-key" = "st_live_..."; "content-type" = "application/json"; "Idempotency-Key" = "00000000-0000-4000-8000-000000000010" } `
-Body (Get-Content -Raw withdrawal.json)
$response | ConvertTo-Json{"authorization_id":"00000000-0000-4000-8000-000000000001:example-intent-digest","amount_usd":"100","destination_wallet":"0x2222222222222222222222222222222222222222","expires_at":"2026-09-13T08:10:00Z","status":"authorized"}
The authorization must still be live when final submission first occurs. After preflight succeeds and funds are ready, send the identical body and Idempotency-Key.
POST/wallet/withdraw#
curl -X POST https://api.superior.trade/wallet/withdraw \ -H "x-api-key: st_live_..." -H "content-type: application/json" \ -H "Idempotency-Key: 00000000-0000-4000-8000-000000000010" \ -d @withdrawal.json
wget -qO- \ --method=POST \ --header='x-api-key: st_live_...' \ --header='content-type: application/json' \ --header='Idempotency-Key: 00000000-0000-4000-8000-000000000010' \ --body-file=withdrawal.json \ https://api.superior.trade/wallet/withdraw
$response = Invoke-RestMethod `
-Method POST `
-Uri "https://api.superior.trade/wallet/withdraw" `
-Headers @{ "x-api-key" = "st_live_..."; "content-type" = "application/json"; "Idempotency-Key" = "00000000-0000-4000-8000-000000000010" } `
-Body (Get-Content -Raw withdrawal.json)
$response | ConvertTo-JsonResponse — 202
{"id":"wd_01j9ab...","status":"processing","amount_usd":100,"destination_wallet":"0x2222222222222222222222222222222222222222","created_at":"2026-09-13T08:03:00Z"}
Identical retries are safe. After acceptance, the matching account, key, and canonical intent replay the existing withdrawal even after proof expiry. Before acceptance, expired proof fails closed. Changed bound fields return 409 idempotency_key_conflict or 409 withdrawal_authorization_mismatch.
4. Poll status#
POST/wallet/withdraw/recover#
Enforced withdrawal endpoints normalize accepted UUID spellings (uppercase, unhyphenated, braced, and urn:uuid: forms) to lowercase hyphenated UUIDs for new authorizations and submissions. Signatures bind this canonical key and recovery request_id responses return it. Locked lookups and recovery check both the exact original spelling supplied and its canonical form; an existing historical claim is never fenced. Different claims, or a claim and a fence, across these identities return 409 idempotency_key_conflict. Recovery fences both spellings only when neither has a claim. Other historical spellings are not enumerated: retry with the exact original key. When enforcement is disabled, legacy submission and replay preserve all trimmed keys unchanged, including UUID aliases. Terminal generates canonical UUIDs.
After an unknown result (including 409 withdrawal_state_conflict), retain the exact original key/body. Later authentication, rate-limit, conflict or expired-proof errors do not establish whether the original withdrawal executed. A replay-only 404 is also insufficient: the original final request may still be in flight.
If proof expires before the outcome is known, recover with the original UUID key and no proof:
curl -X POST https://api.superior.trade/wallet/withdraw/recover \ -H "x-api-key: st_live_..." \ -H "Idempotency-Key: 24fd8259-b9c6-4d6f-8340-ecb5ef924f6d"
wget -qO- \ --method=POST \ --header='x-api-key: st_live_...' \ --header='Idempotency-Key: 24fd8259-b9c6-4d6f-8340-ecb5ef924f6d' \ https://api.superior.trade/wallet/withdraw/recover
$response = Invoke-RestMethod `
-Method POST `
-Uri "https://api.superior.trade/wallet/withdraw/recover" `
-Headers @{ "x-api-key" = "st_live_..."; "Idempotency-Key" = "24fd8259-b9c6-4d6f-8340-ecb5ef924f6d" }
$response | ConvertTo-JsonThe server atomically returns {"status":"existing","request_id":"...","withdrawal":{...}} for any existing claim/resource, without cancelling it. Poll that resource; a processing claim can resume through the identical original final request. When no claim exists, it permanently fences the key and returns:
{"status":"cancelled","request_id":"24fd8259-b9c6-4d6f-8340-ecb5ef924f6d","safe_to_reauthorize":true}
Only this fenced response allows discarding the old proof and authorizing a new request with a new UUID while preserving amount/destination. Every delayed claim using the fenced key is rejected with 409 withdrawal_request_cancelled. If recovery fails, keep the dialog and original request; do not start another withdrawal. The fence never expires and stores no OTP/signature. Recovery is idempotent and remains available when authorization issuance is disabled.
GET/wallet/withdrawals/:id#
After 202, or after an ambiguous final response, poll the durable resource:
curl https://api.superior.trade/wallet/withdrawals/wd_01j9ab... -H "x-api-key: st_live_..."
wget -qO- \ --header='x-api-key: st_live_...' \ https://api.superior.trade/wallet/withdrawals/wd_01j9ab...
$response = Invoke-RestMethod `
-Method GET `
-Uri "https://api.superior.trade/wallet/withdrawals/wd_01j9ab..." `
-Headers @{ "x-api-key" = "st_live_..." }
$response | ConvertTo-Json{"id":"wd_01j9ab...","status":"broadcast","amount_usd":100,"asset":"USDC","chain":"arbitrum","destination_wallet":"0x2222222222222222222222222222222222222222","tx_hash":"0xabc...","created_at":"2026-09-13T08:03:00Z","updated_at":"2026-09-13T08:03:41Z"}
status progresses through processing -> broadcast -> confirmed, or becomes failed with an error object. GET /wallet/withdrawals lists recent resources using limit and cursor.
Errors#
| Status | Representative codes | Action |
|---|---|---|
| 400 | idempotency_key_required, bad_request, invalid_code, withdrawal_otp_identity_invalid | Correct the closed request shape, UUID key, code, or OTP account binding. |
| 401 | withdrawal_signature_invalid, withdrawal_signature_expired | Sign the exact intent with the locked wallet and a fresh expiry. |
| 404 | challenge_not_available, not_found | Request a new OTP; policy/preflight return not_found while disabled. |
| 409 | withdrawal_identity_unavailable, withdrawal_email_unavailable, withdrawal_wallet_unavailable, withdrawal_authorization_mismatch, withdrawal_challenge_mismatch, idempotency_key_conflict, withdrawal_state_conflict | Refresh identity, request fresh proof for changed fields, or reuse only the original key/body. |
| 410 | code_expired | Request a new OTP challenge. |
| 429 | attempt_limit_reached, resend_cooldown, email_limit_reached | Wait for the indicated limit to reset. |
| 502 | wallet_upstream_unavailable | Poll after ambiguity; otherwise retry the identical request. |
| 503 | withdrawal_otp_unavailable, withdrawal_authorization_unavailable, email_delivery_failed, wallet_repository_unavailable | Keep the intent and key; retry after verification, email delivery, or storage recovers. |
Never log an OTP or signature.
Rollout compatibility#
When FEATURE_WITHDRAWAL_OTP=false, policy, OTP, and preflight routes return 404 not_found, while POST /wallet/withdraw accepts the prior amount-only body such as { "amount_usd": 100 } with any nonempty legacy idempotency key. This disabled-feature contract exists only for API-first rollout compatibility. When enabled, authorization is mandatory and failures never fall back to amount-only withdrawal.
The flag also defaults off when unset, blank, or invalid. Production Cloud Build preserves the operator's setting instead of activating it on each deployment. Environment flags are loaded at process startup; apply changes through a new service revision.
Deploy in this order:
- Schema: apply the committed API database migrations, including
0026_add_withdrawal_authorizations.sqland0027_add_withdrawal_authorization_send_events.sql, usingpnpm --dir apps/api db:migrateagainst the active API database. Keep authorization disabled. - Trusted identity repair: deploy the
apps/apibearer bootstrap update. Terminal's existingPOST /v2/account/bootstrapverifies the Privy bearer and profile and refresheslogin_type/login_wallet_address, including users with valid cached keys who never call/auth/verifyor/auth/api-key. API-key requests and request-body identity fields cannot perform this repair. Reload signed-in Terminal sessions and verify both existing email and wallet accounts; an unavailable Privy profile returns a retryable 503 without overwriting identity. - Unified API: deploy authorization support with
FEATURE_WITHDRAWAL_OTP=false. Verify legacy amount-only submission/replay remains available and policy/OTP/preflight return 404. Unified reads repaired identity directly from the active API database; this withdrawal flow never calls the legacy API deployment. - Terminal: deploy the matching authorization UI and proxies, with
NEXT_PUBLIC_FEATURE_DISABLE_WITHDRAWAL=trueduring the activation window. Verify email delivery, wallet signing, cached-key identity repair, and recovery in UAT. Existing open sessions must reload to run bootstrap. The new UI does not fall back to amount-only requests when policy is unavailable. - Activation: explicitly set
FEATURE_WITHDRAWAL_OTP=truein the Unified API service configuration and deploy a new revision. Verify both authorization modes, then set Terminal'sNEXT_PUBLIC_FEATURE_DISABLE_WITHDRAWAL=falseand deploy to reopen withdrawals.
For rollback, first set Terminal's NEXT_PUBLIC_FEATURE_DISABLE_WITHDRAWAL=true and deploy to pause its withdrawal UI and proxy. Set Unified API's FEATURE_WITHDRAWAL_OTP=false and deploy a new revision to restore the legacy contract, then restore a compatible Terminal revision before reopening its withdrawals. Keep the additive schema, repaired identities, authorization records, and accepted withdrawal records; continue polling/reconciling in-flight withdrawals without issuing replacement requests. The Unified flag restores legacy API access; it is not a global funds-movement freeze for direct API clients.