Transfers API
Four kinds, all under /v1/transfers, all requiring transfers:write and all
honoring Idempotency-Key:
| Endpoint | Kind | Use |
|---|---|---|
POST /v1/transfers/internal | internal | Move between two of your own accounts (same provider) |
POST /v1/transfers/external | external | Withdraw to an external wallet or one-time address |
POST /v1/transfers/contract-call | contract_call | Arbitrary EVM contract call from an account |
POST /v1/transfers/approve | approve | ERC-20 approve to a spender |
Read endpoints:
| Method | Path | Scope |
|---|---|---|
GET | /v1/transfers | transfers:read |
GET | /v1/transfers/{id} | transfers:read |
POST | /v1/transfers/{id}/refresh | transfers:read |
GET | /v1/transfers/{id}/signed-tx | transfers:read |
Create an external transfer (withdrawal)
POST /v1/transfers/external
Idempotency-Key: 7c3e6b8a-2f4d-4a9e-9c1b-0e7a5d2f1b9c
{
"srcAccountId": "8f1c...uuid",
"assetCanonical": "USDT_ETHEREUM",
"amount": "250.00",
"destination": { "externalWalletId": "b2d4...uuid" },
"note": "payout #4821"
}
destination is either an allowlisted wallet (externalWalletId) or a
one-time address (with optional memo). Response:
{
"id": "d91a...uuid",
"kind": "external",
"state": "signing",
"srcAccountId": "8f1c...",
"destExternalWalletId": "b2d4...",
"destExternalAddress": null,
"assetCanonical": "USDT_ETHEREUM",
"amount": "250.00",
"idempotencyKey": "7c3e6b8a-...",
"metadata": {},
"createdAt": "2026-05-27T10:10:00.000Z",
"events": [ { "state": "signing", "source": "manual" } ]
}
Transfer states
with held, failed, cancelled, and timed_out as alternatives. Terminal
states are final — once a transfer is completed / failed / cancelled /
timed_out it never transitions again (stale events that try to walk it back are
ignored).
| State | Meaning |
|---|---|
created | Recorded, not yet submitted |
held | Held by policy for approval — not yet signed |
signing | Being signed by the custody backend |
broadcasting | Submitted to the network |
confirming | Seen on-chain, awaiting confirmations |
completed | Confirmed / final |
failed / cancelled / timed_out | Terminal failure states |
Track state changes via webhooks. POST /v1/transfers/{id}/refresh
forces an on-demand refresh of the transfer's custody state if you'd rather pull
than wait.
:::warning A transfer can be held or rejected
If your workspace runs the policy engine in enforce mode, a create call may
return a transfer in state held (an approver must release it in the console) or
fail with 403 if a rule rejects it. Screening can likewise reject with 403.
See Policy & approvals.
:::
Nonce management
For web3-vault signing transfers (contract-call, approve, external,
internal), ZenVault assigns the nonce — it reserves it per-EOA, serialised
across every in-flight transfer from that vault, so two sends from one vault
can't collide on the same nonce (which would silently drop one on-chain). Any
nonce you send is ignored. This does not apply to raw
/v1/signing, where you broadcast and own your own nonce.
Contract-call & approve (EVM)
These accept the standard fields plus an evm block with the gas params the
signer needs:
POST /v1/transfers/contract-call
Idempotency-Key: <uuid>
{
"srcAccountId": "8f1c...",
"chainAssetCanonical": "ETH_ETHEREUM",
"contractAddress": "0xdef1c0ded9bec7f1a1670819833240f027b25eff",
"data": "0x095ea7b3...",
"value": "0",
"note": "swap approval",
"evm": {
"chainId": 1,
"gasLimit": 80000,
"maxFeePerGas": "30000000000",
"maxPriorityFeePerGas": "1500000000"
}
}
- Supply one of
gasPrice(legacy) ormaxFeePerGas+maxPriorityFeePerGas(EIP-1559); neither →400. chainIdandgasLimitare mandatory;nonceis server-assigned (see above).- The same
evmshape is accepted onPOST /v1/transfers/approve.
See the contract-calls guide for the full flow, including fetching the signed bytes.
Fetching signed-tx bytes
For broadcast paths that need the raw signed transaction (swap fills, atomic broadcast windows), poll after creating a contract-call/approve:
GET /v1/transfers/{id}/signed-tx
→ {
"id": "d91a...uuid",
"state": "broadcasting",
"rawState": "BROADCASTING",
"signedTransaction": "0x02f86a01...c3d4",
"txHash": "0xaa11...ff99"
}
signedTransaction is null while signing is in flight — poll until it returns
a non-null hex string. This endpoint queries custody on every call; don't poll
faster than every couple of seconds.
Listing & incremental polling
GET /v1/transfers supports filters: state, assetCanonical, accountId
(source or destination), srcAccountId, destAccountId, and since (ISO
timestamp). For a polling reconciler, page with since + cursor — see
Reconciliation.
Inbound deposits also appear here as external transfers with
srcAccountId: null and metadata.direction: "inbound".