View Markdown

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 typeDestinationProofValidity
EmailEntered for this withdrawalSix-digit email OTP10 minutes (600 seconds)
WalletLocked login walletEIP-712 signature5 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#

bash
curl https://api.superior.trade/wallet/withdraw/authorization \
  -H "x-api-key: st_live_..."

Email-login policy (the destination is entered per request):

json
{"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):

json
{"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.

bash
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"}'
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:

json
{
  "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.

json
{
  "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.

json
{
  "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:

bash
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
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#

bash
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

Response — 202

json
{"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:

bash
curl -X POST https://api.superior.trade/wallet/withdraw/recover \
  -H "x-api-key: st_live_..." \
  -H "Idempotency-Key: 24fd8259-b9c6-4d6f-8340-ecb5ef924f6d"

The 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:

json
{"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:

bash
curl https://api.superior.trade/wallet/withdrawals/wd_01j9ab... -H "x-api-key: st_live_..."
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#

StatusRepresentative codesAction
400idempotency_key_required, bad_request, invalid_code, withdrawal_otp_identity_invalidCorrect the closed request shape, UUID key, code, or OTP account binding.
401withdrawal_signature_invalid, withdrawal_signature_expiredSign the exact intent with the locked wallet and a fresh expiry.
404challenge_not_available, not_foundRequest a new OTP; policy/preflight return not_found while disabled.
409withdrawal_identity_unavailable, withdrawal_email_unavailable, withdrawal_wallet_unavailable, withdrawal_authorization_mismatch, withdrawal_challenge_mismatch, idempotency_key_conflict, withdrawal_state_conflictRefresh identity, request fresh proof for changed fields, or reuse only the original key/body.
410code_expiredRequest a new OTP challenge.
429attempt_limit_reached, resend_cooldown, email_limit_reachedWait for the indicated limit to reset.
502wallet_upstream_unavailablePoll after ambiguity; otherwise retry the identical request.
503withdrawal_otp_unavailable, withdrawal_authorization_unavailable, email_delivery_failed, wallet_repository_unavailableKeep 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:

  1. Schema: apply the committed API database migrations, including 0026_add_withdrawal_authorizations.sql and 0027_add_withdrawal_authorization_send_events.sql, using pnpm --dir apps/api db:migrate against the active API database. Keep authorization disabled.
  2. Trusted identity repair: deploy the apps/api bearer bootstrap update. Terminal's existing POST /v2/account/bootstrap verifies the Privy bearer and profile and refreshes login_type / login_wallet_address, including users with valid cached keys who never call /auth/verify or /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.
  3. 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.
  4. Terminal: deploy the matching authorization UI and proxies, with NEXT_PUBLIC_FEATURE_DISABLE_WITHDRAWAL=true during 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.
  5. Activation: explicitly set FEATURE_WITHDRAWAL_OTP=true in the Unified API service configuration and deploy a new revision. Verify both authorization modes, then set Terminal's NEXT_PUBLIC_FEATURE_DISABLE_WITHDRAWAL=false and 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.