Skip to main content

Transfers API

Four kinds, all under /v1/transfers, all requiring transfers:write and all honoring Idempotency-Key:

EndpointKindUse
POST /v1/transfers/internalinternalMove between two of your own accounts (same provider)
POST /v1/transfers/externalexternalWithdraw to an external wallet or one-time address
POST /v1/transfers/contract-callcontract_callArbitrary EVM contract call from an account
POST /v1/transfers/approveapproveERC-20 approve to a spender

Read endpoints:

MethodPathScope
GET/v1/transferstransfers:read
GET/v1/transfers/{id}transfers:read
POST/v1/transfers/{id}/refreshtransfers:read
GET/v1/transfers/{id}/signed-txtransfers: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

createdsigningbroadcastingconfirmingcompleted

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).

StateMeaning
createdRecorded, not yet submitted
heldHeld by policy for approval — not yet signed
signingBeing signed by the custody backend
broadcastingSubmitted to the network
confirmingSeen on-chain, awaiting confirmations
completedConfirmed / final
failed / cancelled / timed_outTerminal 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) or maxFeePerGas + maxPriorityFeePerGas (EIP-1559); neither → 400.
  • chainId and gasLimit are mandatory; nonce is server-assigned (see above).
  • The same evm shape is accepted on POST /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".