Send a transfer
Create an outgoing transfer with idempotency, understand its states (reserve, awaiting_approval, confirmed), and cancel a stuck request.
A transfer isn't broadcast to the network right away: the amount is reserved first, then the Policy Engine decides allow / queue / block, and only after that do the MPC nodes sign the transaction. This guide shows how to send a transfer, what happens to its state, and how to cancel a stuck request. For how the reserve works, see Ledger; for decisions, see Policy Engine.
The full path of a transfer, from creation to execution or return:
Prerequisites
- A Vault and an address with a balance (
fromAddressId). See the Create a Vault and Generate addresses guides. - An access token (see Authentication).
- Any HTTP client.
Step 1. Send the transfer
POST /api/v1/vaults/{code}/transfers creates an outgoing transfer. Always pass an idempotencyKey: retrying with the same key returns the transfer that was already created instead of duplicating the payment.
Before creating a transfer, you can ask the Policy Engine whether it will go through by calling evaluate (allow/queue/block with no side effects). See Evaluate a transfer.
curl -X POST "[BASE_URL]/api/v1/vaults/acme-otc/transfers" \
-H "Authorization: Bearer $V3_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"token": "usdt",
"amount": "1350.00",
"idempotencyKey": "payout-2026-03-14-001"
}'
const res = await fetch("[BASE_URL]/api/v1/vaults/acme-otc/transfers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.V3_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fromAddressId: "cmr3f41z20001psp7phmyapw3",
toAddress: "TQ2m...p4rX",
token: "usdt",
amount: "1350.00",
idempotencyKey: "payout-2026-03-14-001",
}),
});
console.log(await res.json());
res = requests.post(
"[BASE_URL]/api/v1/vaults/acme-otc/transfers",
headers={"Authorization": f"Bearer {os.environ['V3_TOKEN']}"},
json={
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"token": "usdt",
"amount": "1350.00",
"idempotencyKey": "payout-2026-03-14-001",
},
)
print(res.json())
Under the hood, the platform resolves the address and token, converts the amount using decimals, checks the balance, and runs the request through the Policy Engine (action=transfer). If the transfer is forbidden, you get 403; if funds are insufficient, you get an INSUFFICIENT_BALANCE error.
{
"transaction": { "...": "..." },
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"amount": "1350000000",
"amountDecimal": "1350.00"
}
The amount is returned in two forms: amount in the smallest units (raw) and amountDecimal in human-readable format.
The request body fields shown here are for illustration only. Check the exact request schema on the create transfer page in the API Reference.
Step 2. The three transfer states
The outcome depends on the Policy Engine:
| Outcome | Transfer status | Reserve |
|---|---|---|
allow | executed (in demo → confirmed) | debited |
queue | awaiting_approval | held in reserved |
block | not created (403) | not reserved |
If the transfer is rejected or cancelled, the reserved amount returns to available.
Step 3. Check the status
curl "[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID" \
-H "Authorization: Bearer $V3_TOKEN"
const res = await fetch("[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID", {
headers: { Authorization: `Bearer ${process.env.V3_TOKEN}` },
});
console.log(await res.json());
requests.get(
"[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID",
headers={"Authorization": f"Bearer {os.environ['V3_TOKEN']}"},
).json()
GET /api/v1/vaults/{code}/transfers/{id} returns the status, the amounts (raw and decimal), and, for transfers that require approval, a snapshot of the approvers and their votes.
Step 4. Approve (if the transfer is queued)
If the transfer is in awaiting_approval, members of the approver group vote:
curl -X POST "[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID/approvals" \
-H "Authorization: Bearer $V3_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "decision": "approve" }'
await fetch("[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID/approvals", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.V3_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ decision: "approve" }),
});
requests.post(
"[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID/approvals",
headers={"Authorization": f"Bearer {os.environ['V3_TOKEN']}"},
json={"decision": "approve"},
)
approvecounts toward the threshold; once it's reached, the transfer is executed.rejectis a veto: the transfer immediately becomesrejected, and the funds are returned.- The initiator can't vote on their own transfer; everyone gets one vote.
For more on approval requests, see Approvals.
Check the exact name of the vote field (decision here) on the transfer voting page in the API Reference.
Step 5. Cancel a stuck transfer
If approvers don't act, the initiator can withdraw the transfer while it's awaiting approval:
curl -X POST "[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID/cancel" \
-H "Authorization: Bearer $V3_TOKEN"
await fetch("[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID/cancel", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.V3_TOKEN}` },
});
requests.post(
"[BASE_URL]/api/v1/vaults/acme-otc/transfers/TRANSFER_ID/cancel",
headers={"Authorization": f"Bearer {os.environ['V3_TOKEN']}"},
)
cancel moves the request to rejected and returns the reserved funds. A transfer can't be cancelled once it's been approved or executed.
Common mistakes
| Symptom | Cause |
|---|---|
403 on creation | the transfer was blocked by a policy; check reason / trace |
INSUFFICIENT_BALANCE | not enough available balance on the address |
| token doesn't resolve | the token doesn't match the address's network |
cancel doesn't work | the transfer has already been approved or executed and can't be cancelled |
| payment was duplicated | no stable idempotencyKey was passed |
You're done when
- with
allow, the transfer isconfirmed(in demo); - with
queue, anawaiting_approvalrequest is created and the amount is inreserved; - you can read the status via
GET /api/v1/vaults/{code}/transfers/{id}.