curl --request POST \
--url https://api.arcus.xyz/v1/withdraw \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--header 'X-Signature: <api-key>' \
--header 'X-Timestamp: <api-key>' \
--data '
{
"ethereumAddress": "0x742d35cc6634c0532925a3b844bc9e7595f2bd18",
"amount": "5000000000000",
"nonce": "1730000000000000000",
"accountIndex": 4
}
'import requests
url = "https://api.arcus.xyz/v1/withdraw"
payload = {
"ethereumAddress": "0x742d35cc6634c0532925a3b844bc9e7595f2bd18",
"amount": "5000000000000",
"nonce": "1730000000000000000",
"accountIndex": 4
}
headers = {
"X-API-Key": "<api-key>",
"X-Timestamp": "<api-key>",
"X-Signature": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
'X-API-Key': '<api-key>',
'X-Timestamp': '<api-key>',
'X-Signature': '<api-key>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
ethereumAddress: '0x742d35cc6634c0532925a3b844bc9e7595f2bd18',
amount: '5000000000000',
nonce: '1730000000000000000',
accountIndex: 4
})
};
fetch('https://api.arcus.xyz/v1/withdraw', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.arcus.xyz/v1/withdraw",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'ethereumAddress' => '0x742d35cc6634c0532925a3b844bc9e7595f2bd18',
'amount' => '5000000000000',
'nonce' => '1730000000000000000',
'accountIndex' => 4
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>",
"X-Signature: <api-key>",
"X-Timestamp: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.arcus.xyz/v1/withdraw"
payload := strings.NewReader("{\n \"ethereumAddress\": \"0x742d35cc6634c0532925a3b844bc9e7595f2bd18\",\n \"amount\": \"5000000000000\",\n \"nonce\": \"1730000000000000000\",\n \"accountIndex\": 4\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("X-Timestamp", "<api-key>")
req.Header.Add("X-Signature", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.arcus.xyz/v1/withdraw")
.header("X-API-Key", "<api-key>")
.header("X-Timestamp", "<api-key>")
.header("X-Signature", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"ethereumAddress\": \"0x742d35cc6634c0532925a3b844bc9e7595f2bd18\",\n \"amount\": \"5000000000000\",\n \"nonce\": \"1730000000000000000\",\n \"accountIndex\": 4\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.arcus.xyz/v1/withdraw")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["X-Timestamp"] = '<api-key>'
request["X-Signature"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"ethereumAddress\": \"0x742d35cc6634c0532925a3b844bc9e7595f2bd18\",\n \"amount\": \"5000000000000\",\n \"nonce\": \"1730000000000000000\",\n \"accountIndex\": 4\n}"
response = http.request(request)
puts response.read_body{
"withdrawalId": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"ethereumAddress": "<string>",
"accountIndex": 4,
"amount": "5000",
"status": "PENDING",
"submittedAt": 123
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "rate limited",
"reason": "account_empty",
"retryAfterMs": 850,
"clientId": "my-order-42"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}Submit withdrawal
Submit a withdrawal of collateral to the chain.
curl --request POST \
--url https://api.arcus.xyz/v1/withdraw \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--header 'X-Signature: <api-key>' \
--header 'X-Timestamp: <api-key>' \
--data '
{
"ethereumAddress": "0x742d35cc6634c0532925a3b844bc9e7595f2bd18",
"amount": "5000000000000",
"nonce": "1730000000000000000",
"accountIndex": 4
}
'import requests
url = "https://api.arcus.xyz/v1/withdraw"
payload = {
"ethereumAddress": "0x742d35cc6634c0532925a3b844bc9e7595f2bd18",
"amount": "5000000000000",
"nonce": "1730000000000000000",
"accountIndex": 4
}
headers = {
"X-API-Key": "<api-key>",
"X-Timestamp": "<api-key>",
"X-Signature": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
'X-API-Key': '<api-key>',
'X-Timestamp': '<api-key>',
'X-Signature': '<api-key>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
ethereumAddress: '0x742d35cc6634c0532925a3b844bc9e7595f2bd18',
amount: '5000000000000',
nonce: '1730000000000000000',
accountIndex: 4
})
};
fetch('https://api.arcus.xyz/v1/withdraw', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.arcus.xyz/v1/withdraw",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'ethereumAddress' => '0x742d35cc6634c0532925a3b844bc9e7595f2bd18',
'amount' => '5000000000000',
'nonce' => '1730000000000000000',
'accountIndex' => 4
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>",
"X-Signature: <api-key>",
"X-Timestamp: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.arcus.xyz/v1/withdraw"
payload := strings.NewReader("{\n \"ethereumAddress\": \"0x742d35cc6634c0532925a3b844bc9e7595f2bd18\",\n \"amount\": \"5000000000000\",\n \"nonce\": \"1730000000000000000\",\n \"accountIndex\": 4\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("X-Timestamp", "<api-key>")
req.Header.Add("X-Signature", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.arcus.xyz/v1/withdraw")
.header("X-API-Key", "<api-key>")
.header("X-Timestamp", "<api-key>")
.header("X-Signature", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"ethereumAddress\": \"0x742d35cc6634c0532925a3b844bc9e7595f2bd18\",\n \"amount\": \"5000000000000\",\n \"nonce\": \"1730000000000000000\",\n \"accountIndex\": 4\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.arcus.xyz/v1/withdraw")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["X-Timestamp"] = '<api-key>'
request["X-Signature"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"ethereumAddress\": \"0x742d35cc6634c0532925a3b844bc9e7595f2bd18\",\n \"amount\": \"5000000000000\",\n \"nonce\": \"1730000000000000000\",\n \"accountIndex\": 4\n}"
response = http.request(request)
puts response.read_body{
"withdrawalId": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"ethereumAddress": "<string>",
"accountIndex": 4,
"amount": "5000",
"status": "PENDING",
"submittedAt": 123
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}{
"error": "rate limited",
"reason": "account_empty",
"retryAfterMs": 850,
"clientId": "my-order-42"
}{
"error": "Invalid request body",
"code": "GEO_RESTRICTED",
"errorSource": "Order",
"errorType": "Tick",
"rejectionReason": "POST_ONLY_WOULD_CROSS"
}amount is in quote quantums.
Two mutually exclusive authentication modes are supported, selected by the presence of the X-API-Key header:
- Wallet-signed (default): the request is self-authenticated by a secp256k1 EIP-712 typed-data signature in the body’s
signaturefield — noX-API-Key/X-Signatureheaders. This is the original v0 mode, unchanged. - API-key-signed: the request carries the
X-API-Key/X-Timestamp/X-Signatureheader triple with an Ed25519 signature over the ordersignWithdrawV1typed canonical payload (see below); the bodysignaturefield is ignored. The key must be bound to exactly the requested(ethereumAddress, accountIndex)and must carry the opt-inwithdrawpermission (grantable only through the operator’s provisioning flow — keys from the publicPOST /createApiKeyare always trade-only and are rejected with HTTP 403). This mode exists for contract-owned accounts (e.g. vaults), which cannot produce wallet signatures.
ethereumAddress), so there is no to field — an API key can only move funds to the account owner’s own address.
Response behavior
Asynchronous. A202 Accepted means the withdrawal passed validation and was queued to the matching engine with status: PENDING. The definitive outcome (applied, or rejected for insufficient free collateral) is delivered asynchronously — poll GET /v1/accountTransferUpdates or subscribe to the account transfer update WebSocket channel and correlate on withdrawalId.
Signing the request
Withdrawals use EIP-712 typed data (eth_signTypedData_v4), so wallets render each field and institutional / MPC signers can whitelist on the domain. Sign this typed data with the wallet that owns ethereumAddress, then split the 65-byte result into {r, s, v} for the signature field. The gateway recovers the signer and rejects mismatches with HTTP 401.
Domain
{
"name": "Arcus Withdraw",
"version": "1",
"chainId": <rootchain chain id, e.g. 421614>,
"verifyingContract": "<BridgeVault address for that chain>"
}
Withdraw(address ethereumAddress,uint8 accountIndex,uint256 amount,string nonce)
{
"ethereumAddress": "<ethereumAddress>",
"accountIndex": <accountIndex>,
"amount": "<amount>",
"nonce": "<nonce>"
}
amount is the integer collateral quote-quantum amount (same value as the amount request field).
Per-environment domain parameters
| Environment | chainId | verifyingContract (BridgeVault) |
|---|---|---|
| staging | 421614 | 0xfcb43af23e80dbbe7d951af49aa4eefb4eff8c2c |
| testnet | 46630 | 0x9a6d3499149fea853efe775a3577701539054eaf |
| production | 4663 | 0x14b107cf534239c59571b066cb6497a321da897c |
eth_signTypedData_v4 call (ethers v6 / viem)
// ethers v6
const domain = {
name: "Arcus Withdraw",
version: "1",
chainId: 421614, // staging
verifyingContract: "0xfcb43af23e80dbbe7d951af49aa4eefb4eff8c2c"
};
const types = {
Withdraw: [
{ name: "ethereumAddress", type: "address" },
{ name: "accountIndex", type: "uint8" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "string" }
]
};
const message = {
ethereumAddress: "0xYourAddress",
accountIndex: 0,
amount: BigInt("5000000000000"), // quote quantums
nonce: "b1c2d3e4-5f60-7182-93a4-b5c6d7e8f901"
};
const sig = await signer.signTypedData(domain, types, message);
// Split into r / s / v for the request body:
const r = sig.slice(0, 66);
const s = "0x" + sig.slice(66, 130);
const v = "0x" + sig.slice(130, 132);
// viem
const sig = await walletClient.signTypedData({ domain, types, primaryType: "Withdraw", message });
v component must be 0x1b (27) or 0x1c (28); wallets that return 0 / 1 should have 27 added before sending.
Signing the request (API-key mode)
TheX-Signature header carries a lowercase-hex Ed25519 signature over the ordersign WithdrawV1 typed canonical payload — the same compact key-sorted-JSON convention as placeOrder. The payload is built from parsed request values (never raw body bytes):
{"ad":"<ethereumAddress, lowercase 0x hex>","ai":<accountIndex>,"ct":<X-Timestamp value, ns>,"n":"<nonce, verbatim>","op":5,"q":<amount, quote quantums as integer>,"v":1}
ad, ai, ct, n, op, q, v) with no whitespace. ct must equal the X-Timestamp header value (nanosecond epoch), binding the signature to the drift window; op is fixed at 5 (withdraw — an order signature can never authorize a withdrawal); q is the integer quote-quantum amount (same value as the amount field, unquoted); n is the body nonce signed verbatim.
Example (payload → signature):
payload = {"ad":"0x1111111111111111111111111111111111111111","ai":0,"ct":1718644999000000000,"n":"1718644999000","op":5,"q":2000000000000,"v":1}
X-Signature = hex(ed25519_sign(privateKey, payload))
X-Timestamp drift window bounds how long a captured signature stays valid, and the nonce is single-use per (ethereumAddress, accountIndex) exactly as on the wallet-signed mode (409 on reuse).Authorizations
Hex-encoded Ed25519 public key (64 chars). The public key IS the API key — register it via POST /createApiKey. Required on every authenticated request, both read-only and signed.
Unix time in nanoseconds as a decimal string (e.g. "1713825891591000000"). Millisecond or second epochs are rejected with 401 Unauthorized. Must be within ±30,000 ms (MaxTimestampDriftMs, the drift window stays configured in milliseconds) of server wall-clock, or the request is rejected with 401 Unauthorized. Required on all mutating / credential-creating endpoints. This same value must appear as the ct field in the ordersign typed canonical payload (single-order endpoints) or in each element's ct field (batch endpoints).
Lowercase hex-encoded Ed25519 signature (128 chars).
Single-order endpoints (placeOrder, cancelOrder, modifyOrder, and other non-batch mutating routes) sign over the ordersign typed canonical payload — a compact, key-sorted JSON object built from parsed request fields using engine-native integer values:
placeOrder: {"ad":"0x…","ai":N,[,"c":"…"],"ct":N,"g":N,"m":N,"op":1,"p":N,"q":N,"r":0|1,"s":N,"t":N,"v":1}
cancelOrder: {"ad":"0x…","ai":N,[,"c":"…"],"ct":N,[,"id":"…"],"m":N,"op":2,"v":1}
modifyOrder: {"ad":"0x…","ai":N,[,"c":"…"],"ct":N,"g":N,[,"id":"…"],"m":N,"op":3,"p":N,"q":N,"r":0|1,"s":N,"t":N,"v":1} (exactly one of id / c)
ct must equal the X-Timestamp header value. Keys in brackets are conditional (omitted when empty). op values: 1=place, 2=cancel, 3=modify. See the ordersign package for field definitions and reference signing code.
Other signed routes (e.g. createApiKey) still use the legacy scheme: signing_message = X-Timestamp + ACTION + canonicalJSON(body), where ACTION is the camelCase final path segment.
Batch endpoints (batchPlaceOrders, batchCancelOrders, batchModifyOrders) do NOT use this header. They authenticate with per-element typed ordersign signatures embedded in the request body (see the global auth description and the per-field signature descriptions on OrderRequest / CancelOrderRequest / ModifyOrderRequest).
Read endpoints are authenticated by ?address= (and optionally X-API-Key) only — no signature is required. canonicalJSON(body) is the JSON body with object keys sorted lexicographically at every level and no whitespace; the server canonicalizes the received body before verifying, so only the bytes signed over must be canonical. Required on all mutating / credential-creating endpoints.
Body
Master EVM address initiating the withdrawal. In v0 this is also the on-chain recipient (withdraw-to-self) — there is no separate destination field.
^(0x|0X)?[0-9a-fA-F]{40}$"0x742d35cc6634c0532925a3b844bc9e7595f2bd18"
Amount to withdraw, as a base-10 integer string of quantums. The unit is quote quantums (1e9 = $1; for example, 1000000000 represents $1); requests below the minimum withdrawal size are rejected with HTTP 400 (e.g. validation error on field 'amount': must be at least 1000000000 quote quantums), and the amount must be exactly representable in collateral base units. Amounts must be positive and fit in a signed 64-bit integer; float-style decimals are rejected.
^[0-9]+$"5000000000000"
Replay-protection nonce, single-use per (ethereumAddress, accountIndex); a repeated nonce is rejected with 409. Send the current time as nanoseconds since the Unix epoch (a plain base-10 integer, e.g. 1730000000000000000); the server accepts a timestamp up to 48 hours in the past by default (covers slow multi-approver / MPC custody signing) and up to 24 hours in the future by default (clock skew only) — both bounds are configurable per environment, so treat these as defaults, not guarantees. A legacy opaque nonce (e.g. a UUID) is still accepted for backward compatibility but is deprecated — prefer the nanosecond timestamp form. At most 64 characters.
1 - 64"1730000000000000000"
Trading account to debit. Defaults to 0 when omitted.
0 <= x <= 9secp256k1 EIP-712 typed-data signature (r, s, v) produced with eth_signTypedData_v4 by the wallet that owns ethereumAddress. The primary type is Withdraw — see the POST /v1/withdraw endpoint description for the exact domains, types, and messages. Requests whose recovered signer does not match are rejected with HTTP 401. Conditionally required: mandatory on the default wallet-signed path (its absence is a 400); ignored on the API-key path, where the request instead carries the X-API-Key / X-Timestamp / X-Signature headers and the Ed25519 WithdrawV1 proof lives in the X-Signature header.
Show child attributes
Show child attributes
Response
Withdrawal accepted and queued to the matching engine. The body carries status: PENDING and a withdrawalId; the terminal outcome is delivered on the account transfer update stream.
Server-generated identifier (UUID) correlating this withdrawal with the matching AccountTransferUpdate on the account transfer update stream.
"b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
20-byte EVM address as hex: optional 0x or 0X prefix and exactly 40 hexadecimal digits. API responses normalize to lowercase a–f after 0x.
^(0x|0X)?[0-9a-fA-F]{40}$Account index (account index, 0–9). Identifies the account for orders, positions, fills, and API keys.
0 <= x <= 9Withdrawal amount echoed back as a human-readable decimal in quote currency (quote resolution 1e9), matching how the amount is reported on the account transfer update feed.
"5000"
"1.5"
Synchronous status of a submitted withdrawal. Always PENDING on the 202 response — the withdrawal has been queued to the matching engine but not yet applied. The terminal outcome (APPLIED, REJECTED_INSUFFICIENT_COLLATERAL, ...) is delivered asynchronously on the account transfer update stream (see AccountTransferUpdate).
PENDING Server receive timestamp (epoch microseconds).
Was this page helpful?