Create a transfer
The single money-movement resource. The pair (source.type, destination.type) selects the flow - you never call a different endpoint for on-ramp vs off-ramp:
| source | destination | flow | availability |
|---|---|---|---|
| fiat_payment | payout_destination | FX (fiat→fiat) | live |
| fiat_payment | wallet | on-ramp | live |
| account | wallet | on-ramp from the named account's balance | live |
| wallet | payout_destination | off-ramp | live |
| wallet | account | off-ramp to the customer's own account | live |
| wallet | wallet | wallet transfer | live |
| wallet | crypto_address | crypto send | live |
Sending to an on-chain address runs on this route. A wallet source with a crypto_address destination is a custody move: OZAV signs from the source wallet's vault and broadcasts. No quote is involved (it is a same-asset move, not an exchange), so send amount and omit quote_id. It needs the custody or the liquidity product. The /wallets routes need custody: listing wallets (GET /wallets), reading a customer's wallet, its deposit addresses, addresses and policy, and POST /wallets/{wallet_id}/transfers. It is subject to the two controls below.
On-ramp from the account balance. An account source spends the BRL balance of the customer's BRL named account: Pix payments to the account's key accumulate there, and nothing converts by itself. An account that cannot fund an order yet gets 422 ACCOUNT_NOT_READY: it is not active, its institution cannot send Pix, or its balance could not be read just now. Converting more than the balance not already committed to an open order gets 422 ACCOUNT_BALANCE_INSUFFICIENT. If OZAV pauses this flow, every organization gets 422 ACCOUNT_FUNDED_ORDERS_NOT_ENABLED at once: nothing in your request causes it.
Some of these refusals come after the transfer is recorded: it ends failed, and a retry with the same Idempotency-Key returns the same refusal. To try again later, send the request with a new key. An ACCOUNT_NOT_READY that persists across a few retries means the account cannot fund orders yet. Each conversion is one transfer with a quote, like any on-ramp, and the BRL 1,000 on-ramp floor applies per transfer, not per deposit. A smaller deposit stays in the account and adds up with the next ones.
In the sandbox, a test key converts from a sandbox account: credit it first with POST /accounts/{account_id}/simulate/credit, then send the transfer. The order runs on the simulator and the conversion debits the simulated balance.
Wallet-to-wallet runs here too. The source wallet must belong to the customer_id on the request - that is whose money moves. The destination only has to be a wallet in your organization, so you can move value between two of your customers' wallets. A wallet outside your organization is not found, and a deactivated wallet does not receive.
The two controls:
- Managed wallets only. A self-custody source is refused with 422 WALLET_NOT_MANAGED - OZAV holds no key to sign with. That send goes through POST /wallets/{wallet_id}/transaction-intents, which returns unsigned calldata for the end-user's own key.
- Travel Rule (BCB Resolution 520) on any send that leaves custody. A crypto_address destination without complete Travel Rule data is refused with 422 TRAVEL_RULE_REQUIRED, and details.missing_fields names each field that is missing. A destination that is an ozav_wal_ id is exempt: the move stays inside custody and both sides are already verified.
POST /wallets/{wallet_id}/transfers also carries a destination memo, which networks like Stellar need for exchange deposits (an exchange credits by memo). POST /transfers has no memo field, so a memo-bearing send belongs on the wallets surface even after custody lands here.
Receiving crypto needs nothing from you, and is not a transfer. Fund an address from GET /wallets/{wallet_id}/deposit-addresses and the credit lands on the wallet by itself. You see it in two places already:
- GET /wallets/{wallet_id}/balances reflects the new amount;
- GET /transactions lists the arrival alongside your transfers, in one timeline, so you do not have to stitch two listings together.
Creating a transfer with a crypto_payment source is refused with 422 NOT_AVAILABLE, and that refusal is permanent rather than pending: an arrival is observed, never requested, so there is nothing for you to create. The one thing still missing is a push notification for it - today you learn about an arrival by reading, not by being told.
BRL/Pix on-ramp returns transaction-specific Pix payment instructions on source.payment_instructions. BRL/Pix off-ramp creates the operation only after the customer's stable funds are reserved; final Pix-out remains gated until settlement rails are enabled. Other combinations fail closed (422 RAIL_NOT_AVAILABLE / NOT_AVAILABLE) until settlement is enabled - never a silent partial result. See x-ozav-transfer-flow-availability for the machine-readable status.
quote_id is required whenever source and target currencies differ, and is authoritative for the amount when present. For a same-currency move (no FX, so no quote) provide amount - it is how much to move. Exactly one of quote_id or amount must be present; supplying both is a 400. The Idempotency-Key header is required.
Request
- Base URLhttps://developers.ozav.io/v2
- Auth
- API key in header X-API-Key
- or HTTP bearer
Headers
Client-generated unique key: 16-256 characters of [A-Za-z0-9_-]. Same key + same body replays the original response (Idempotent-Replayed: true); same key + different body returns 409 IDEMPOTENCY_KEY_REUSED; a concurrent duplicate returns 202.
Request body
Example request
{
"amount": {
"amount": "1250.00"
},
"travel_rule": {
"originator": {
"identity_documents": [
{
"type": "cpf",
"number": "11144477735",
"issuing_country": "BR"
}
],
"address": {
"country": "BR"
}
},
"beneficiary": {
"identity_documents": [
{
"type": "cpf",
"number": "11144477735",
"issuing_country": "BR"
}
],
"address": {
"country": "BR"
}
}
}
}Response
Degraded success - the transfer was dispatched, but finalizing the record afterwards failed (projection persist, owner binding, or serialization). The payment may already be on its way, so nothing is rolled back: the transfer is downgraded to needs_review and this reduced body is what you get. It is not the full Transfer resource - source, destination, amounts and quote_id are absent, and a client that assumes the 201 shape here will read undefined on the one response that means "money may have moved".
Branch on the status code, not on res.ok. On a 200, treat the transfer as unknown and reconcile with GET /transfers/{id} - do not retry the create, and do not present it to the payer as failed. A retry with the same Idempotency-Key replays this same body.
Changes
No recorded changes to this operation across all 1 revision of this API.