Transfers

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:

sourcedestinationflowavailability
fiat_paymentpayout_destinationFX (fiat→fiat)live
fiat_paymentwalleton-ramplive
accountwalleton-ramp from the named account's balancelive
walletpayout_destinationoff-ramplive
walletaccountoff-ramp to the customer's own accountlive
walletwalletwallet transferlive
walletcrypto_addresscrypto sendlive

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.

post/transfers

Request

  • Base URL
    https://developers.ozav.io/v2
  • Auth
    • API key in header X-API-Key
    • or HTTP bearer

Headers

Idempotency-Keystring required

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

customer_idstring required

The ozav_cus_ id of the customer this transfer is for.

quote_idstring

The ozav_q_ id of the quote to execute. Required when source and target currencies differ; authoritative for the amount when present.

metadataMetadata

Free-form string map (max 50 keys).

facility_terms_acceptedboolean

The FX rate lock (trava de câmbio), on this transfer. Send true, together with facility_terms_version, to lock the rate at acceptance instead of converting at the price of the moment the money actually moves.

Both fields are optional and omitting them is the normal path - a transfer without them behaves exactly as before and the response carries no facility block.

Only BRL/Pix flows honour the lock. The fields validate on every combination, but on a crypto_address destination, a wallet-to-wallet move, or the BRS redemption corridor they are accepted and have no effect - no lock is taken and no facility block is returned. Always branch on the presence of facility in the response rather than assuming the request implied a lock. The lock is a contracted facility: your organization needs a signed schedule (Termo) and a funded reserve before it is available, and without them the request is refused with 422 FACILITY_LOCK_UNAVAILABLE.

Strictly true, not truthy. "true", 1 and {} all read as "no lock requested", silently - the transfer is created unlocked.

Get the version from the preview first: POST /quotes with facility_preview: true returns a facility block whose terms_version is the only value this route accepts. Accepting without a version is refused with 422 FACILITY_TERMS_VERSION_REQUIRED, because an acceptance that does not say what was accepted cannot support a charge later.

Where the lock actually engages differs by direction, and the off-ramp case is not obvious. On a BRL/Pix on-ramp it engages at acceptance. On a BRL/Pix off-ramp it engages only when the customer's custody balance is insufficient - with enough balance the withdrawal takes the ordinary path and the lock does not apply. That outcome is reported in the response's facility block rather than passed over in silence.

facility_terms_versionstring

The terms_version returned by the facility preview (POST /quotes with facility_preview: true). Required whenever facility_terms_accepted is true; rejected as stale when it does not match the schedule currently in force for your organization (422 FACILITY_LOCK_UNAVAILABLE with details.reason = terms_version_mismatch on the on-ramp, 409 PREFUNDING_TERMS_VERSION_STALE on the off-ramp). Re-read the preview and accept again.

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.

idstring required

The transfer id - use it to reconcile.

object'transfer' required
status'needs_review' required

Changes

No recorded changes to this operation across all 1 revision of this API.