openapi: 3.1.0
info:
  title: OZAV API
  version: 2.0.0
  summary: Canonical OZAV platform API (v2)
  description: |
    The OZAV API v2 is the canonical, single interface to OZAV's full-stack
    crypto and banking-as-a-service platform. New here? Read **What is OZAV**
    and **Choose your integration** below, then check **What's available
    today** - applications are open, endpoints are enabled per organization,
    and not every resource is callable yet.

    ## What is OZAV

    OZAV is a full-stack crypto and banking-as-a-service platform. You
    integrate once and get identity onboarding and compliance, cross-border FX,
    and wallets/custody - behind one API and one settlement layer, instead of
    wiring up a different counterparty for each capability.

    One API serves four integration profiles from the **same** canonical
    endpoints: a full-stack neobank, a liquidity-only integration, a
    compliance-only integration, or a wallets integration. You never call a
    different API for a different shape of business - access is governed by
    **entitlements** (the products enabled for your organization) and **key
    scopes** (what a given key may do). Call `GET /capabilities` first; it
    returns exactly which entitlements, scopes, and features your key has.

    What you get, grouped:

    - **Onboarding & compliance** - create customers, run KYC/KYB
      verifications, and screen subjects against designated lists.
    - **FX & cross-border** - price a transfer with transparent exchange
      conditions (a spread, in basis points) and move money across corridors.
    - **Accounts** - expose customer-owned named fiat receiving instructions
      under an OZAV-owned `ozav_acc_` account id. BRL/Pix is the first
      sandbox-active rail; future fiat rails use the same resource.
    - **Wallets & custody** - read managed and self-custody wallets and, for
      self-custody, build unsigned transactions the end-user signs.
    - **Cards** - issue and manage OZAV virtual cards through one canonical
      v2 surface, with sandbox issuance available and live programs enabled per
      organization.

    **The machine-readable contract lives at a stable URL.** Fetch
    `https://ozav.io/openapi.yaml` (or `.json`) and generate a client. It is the same
    file this page renders, copied at build time, so the URL and the page never drift.

    ```
    npx openapi-typescript https://ozav.io/openapi.json -o ozav.d.ts
    ```

    OZAV is provider-neutral: pricing is always expressed as **exchange
    conditions** (a spread in integer basis points), amounts are decimal
    strings (never floats), identifiers are opaque and prefixed (`ozav_cus_…`,
    `ozav_q_…`, `ozav_tr_…`, `ozav_acc_…`), and the environment (test vs live)
    is derived from your key.

    ## Changelog

    Behavior changes that a generated client cannot see. The spec version is
    unchanged because nothing here removes or renames a field, but the wire
    shape you receive can differ, so read this before assuming a field is
    always present.

    - **2026-09-10 - `account_status` says whether the account can operate, and
      the refusal has its own code.** Two additions and one renamed error, all from
      the same gap: an approved customer whose account is not operable had no field
      saying so. `Customer.account_status` (`active` | `pending`) is that field.
      `requirements.due_codes` carries the same list as `due` in stable English
      tokens, using the `missing_fields` vocabulary; `due` itself is unchanged.

      The refusal from `POST /accounts` for a customer whose verification is
      `approved` but whose account is not active now returns `422
      ACCOUNT_NOT_ACTIVATED` instead of `422 KYC_NOT_APPROVED`, and its message
      points at `requirements.due`. The old code said the customer must be approved
      while `status` already read `approved`.

      What this means for you. Both fields are additive - ignore them and nothing
      changes. If you switch exhaustively on error codes, add
      `ACCOUNT_NOT_ACTIVATED`; `KYC_NOT_APPROVED` still fires for a customer whose
      verification really has not been approved. And stop inferring operability from
      `status`: read `account_status`.
    - **2026-09-10 - An approved customer can still be waiting on you, and
      `requirements.owner` now says so.** A customer whose verification came back
      `approved` is not necessarily able to move money: the account also has to
      carry the data a withdrawal requires (holder name, a valid tax id, and a
      street + city address). When it does not, `status` is `approved`,
      `requirements.due` names exactly what is missing, and the account stays
      inactive - `Wallet.status` reads `inactive`, and `POST /accounts` and
      `POST /cards` refuse.

      What changed on this date is the `owner` field. Before, that state returned
      `owner: none`, which this spec documents as "the decision is final" - and
      integrators read it as "nothing to do", so the customer sat there. It now
      returns `integrator` whenever the decision is `approved` and `due` is
      non-empty. A `rejected` decision still returns `none`: no address reverses a
      verdict, and sending you to collect one would cost your customer a trip for
      nothing.

      What this means for you. Read `requirements.due` even when
      `decision.status` is `approved` - `documents_due` and `decision.status`
      alone no longer tell you everything that is owed. Send the named field with
      `PATCH /customers/{customer_id}`: the check re-runs on that call, and when
      it passes, the account activates and `due` comes back empty in the same
      response. Until then the refusal from `POST /accounts` and `POST /cards`
      surfaces as `KYC_NOT_APPROVED`, whose message says the customer must be
      approved; when `status` is already `approved`, the real reason is
      `requirements.due`, not the verification.
    - **2026-09-08 - The decision, the clock and the document verdict are on the
      customer.** `Customer.decision` says whether the customer was approved and,
      if not, why (closed `reason_code`) and whose move it is
      (`requirements.owner`); `Customer.sla` is OZAV's response clock (48 business
      hours to decide once the record is complete; 30 days for a document and 15
      for a correction on your side); `requirements.documents_due[]` now names the
      `doc_type` to upload; and `CustomerDocument.review` is OZAV's own verdict on
      a supporting document, with a closed `reason_code` when rejected. New
      webhook `document.reviewed`. `customer.updated` is now also emitted on a
      manual approval, not only on rejection. Nothing was removed or renamed.
    - **2026-09-05 - A verification can be born already decided when OZAV has
      verified that person before.** `POST /verifications` for an individual
      whose tax identity OZAV has already verified in a hosted session (for any
      organization, within the validity window) no longer starts a new hosted
      session: `session_url` is `null`, and `status`/`checks` for the hosted
      identity capabilities arrive with the reused outcome instead of
      `pending`/`not_started`. Nothing about the other organization is
      disclosed. Treat `session_url: null` together with a non-`pending`
      status as "no user step needed". Off by default; enabled per
      environment by OZAV.
    - **2026-08-31 - The Travel Rule now follows the customer's country instead of
      requiring a Brazilian tax document from everyone.** An external crypto send
      previously demanded `cpf` or `cnpj` from every originator, in both
      environments, which made the call impossible for a customer outside Brazil.
      Accepted documents are now resolved per country: `BR` keeps `cpf`/`cnpj`,
      `NG` accepts `bvn`/`nin`/`passport`/`national_id`, and any other served
      country accepts `passport`/`national_id`.

      Why: the requirement was one hardcoded predicate with no country parameter,
      on an API that serves customers in several countries.

      What this means for you. Nothing breaks for a Brazilian customer - that path
      is unchanged. The country comes from the customer record - what you declared
      when creating the customer, not the country inside your `travel_rule` payload -
      so it is not selectable per request. Correcting it later via
      `PATCH /customers/{id}` is allowed and now passes the same jurisdiction check as
      creation. Two smaller changes ship with it:
      `beneficiary.wallet_attested_ownership_at` must now be a real ISO-8601
      timestamp that is not in the future (any non-empty string was accepted
      before), and the `missing_fields` entry for the document now names the types
      accepted for that country rather than always `[cpf|cnpj]`.

    - **2026-08-30 - Webhook deliveries are now scoped to the environment of
      the key that produced the event.** An endpoint registered with a live key
      no longer receives sandbox events, and a sandbox endpoint no longer
      receives live ones.

      Why: a single endpoint receiving both meant test traffic reached
      production handlers, and there was no way to tell the two apart from the
      payload alone.

      What this means for you. If you registered one endpoint and relied on it
      catching both environments, register one per environment - otherwise half
      your deliveries stop arriving. Endpoints registered through the dashboard
      before this change carry no environment and still receive both; that is
      being addressed separately.

    - **2026-08-30 - `POST /wallets` refuses self-custody registration on
      networks with no way out.** `tron` and `stellar` now return `422
      CHAIN_HAS_NO_SELF_CUSTODY_CAPABILITY` instead of `201`.

      Why: registration succeeded on all nine networks, and the dead end only
      appeared on the next call - `POST /transfers` does not accept a
      self-custody source, and `transaction-intents` builds EVM transactions
      only. A wallet that cannot move is worse than a refusal, because it looks
      like it worked.

      What this means for you. Use managed custody on those two networks. Note
      `POST /customers` still accepts them in `self_custody.chain`, since they
      remain valid networks elsewhere in the API. `solana` remains registrable
      but is not served by `transaction-intents` either - it unlocks the
      bring-your-own-wallet yield leg.

    - **2026-08-30 - Card transactions and authorizations carry `presentment`,
      and `400` responses name the failing field.** `presentment` holds the
      merchant's own amount and currency; an international purchase is
      `presentment.currency != currency`. The key is ABSENT when the issuer did
      not report it and is never back-filled with the card currency, so treat
      absent as unknown, not as domestic. `error.param` now names the failing
      field on body validation, across the whole API. The card activity shape is
      also typed in the spec now (`CardActivity`); it was previously untyped.

      Why: the issuer's currency was being discarded, so there was no way to
      identify an international purchase from the API. Comparing
      `merchant.country` answers a different question and gets it wrong for a
      merchant registered in one country and billing in another.

      What this means for you. Nothing breaks: `presentment` is additive and
      optional. `simulate/purchase` accepts it so you can exercise the handling
      in sandbox first.

    - **2026-08-25 - `requirements.missing_fields` reports seven more fields on
      every customer.** The closed `MissingField` enum went from 19 to 26
      values. For an `individual` the list now also reports
      `monthly_income_usd`, `occupation`, `nationality`, `is_pep` and
      `account_purpose`; for a `business`, `annual_revenue_usd`. Two more,
      `regulator` and `regulated_activity_details`, are CONDITIONAL and appear
      only while `business.is_regulated_activity` is `true`.

      Why: the economic-capacity assessment compares the expected volume
      against a declared capacity, and the API asked only for the volume. In the
      30 days before this change, 322 of 328 assessments returned no verdict
      because the capacity was never collected.

      What this means for you. Nothing server-side is gated on this list, so no
      existing customer is newly blocked by OZAV and no request that worked
      yesterday fails today. What changes is the CONTENT of an informational
      array: a customer that returned `missing_fields: []` yesterday can return
      up to nine tokens now. Two integration patterns are affected. If you
      switch exhaustively on the enum, add the seven values. If you treat a
      non-empty `missing_fields` as "block the user", read the next entry first,
      because that pattern has a second problem that predates this change.

    - **2026-08-31 - `owners` now clears, and `representative` joins it.**
      Supersedes the 2026-08-25 entry below. `GET /customers/{customer_id}`
      resolves the associated persons and reports both tokens truthfully, so an
      empty `missing_fields` is reachable again for a Brazilian business. The
      LIST endpoint still does not resolve them (it would cost one query per
      row), so treat `owners` and `representative` on a list page as unresolved
      rather than outstanding. `representative` is a SEPARATE requirement from
      `owners`: a company whose only person is `roles: ["ubo"]` at 100% has an
      owner but nobody who can sign, and an account cannot be opened for it in
      any currency.

    - **2026-08-25 - `owners` is reported for every business, including one
      whose UBOs are already on file.** Superseded on 2026-08-31 by the entry
      above; kept because integrations written against that behaviour need to
      know it changed. It described a real limitation: neither read path
      supplied the qualifying-owner count, so the token stayed in the list for a
      Brazilian business no matter how many associated persons you created.

    - **2026-08-23 - `/wallets/{wallet_id}/onchain-balances` lists only the
      networks offered to your organization.** It used to list every network on
      which OZAV happened to hold an address for you, drawn from the full
      `Network` enum. It now lists the same set `/deposit-addresses` serves:
      the proven-withdrawal ceiling intersected with your configuration. No
      field was removed or renamed, so a generated client cannot see this - what
      changes is that entries for networks you were never offered stop arriving.
      If you key a map by `network` or iterate `data` expecting one entry per
      address, expect fewer entries. Balances already sitting at an address on a
      withdrawn network are unaffected on-chain. Read
      `x-ozav-network-availability` on the `Network` schema for which networks
      apply to you.

    - **2026-08-19 - `approved` no longer implies the customer can operate.**
      OZAV now activates a customer only when it also holds the holder name, a
      valid tax document for the declared country, and an address (a Brazilian
      company with a valid CNPJ is exempt). A customer can sit at
      `verification.status: approved` and still be refused with `422` on
      `POST /accounts` and the money paths. The wire shape does not change, so a
      generated client cannot see this: read `CustomerStatus` for what to send.

    - **2026-08-23 - `failure_reason` on an account is now a code list, and is absent more

      often.** It used to be whatever the underlying institution wrote, verbatim. It is now a

      comma-separated list of the codes documented on the `Account` schema, or absent. If you

      displayed the string to an end user it will read differently; if you parsed it, parse the

      codes instead. Account webhooks carry the same object, so payloads persisted before this

      date hold the old shape.

    - **2026-08-19 - `ach` is no longer served, in any environment.** The rail
      moved from `sandbox only` to **not offered**: `POST /accounts` with
      `currency: USD, rail: ach` now answers `422 ACCOUNT_RAIL_NOT_AVAILABLE`
      with a `test` key too, where it previously succeeded. This is a
      commercial decision, not an outage, and no deprecation window preceded
      it. **The enum value stays published** so records already carrying it keep
      reading; only new requests are refused. `POST /payout-destinations` with
      `rail: ach` answers `422 RAIL_NOT_AVAILABLE` from the provider registry,
      which is a different code from a different source of truth.

    - **2026-08-19 - a new organization no longer receives every product.**
      Organizations are created entitled to customer management, KYC/KYB,
      liquidity and managed custody. **`cards`, `kyc_reliance` and
      `self_custody` are no longer granted at creation**, so a fresh sandbox key
      calling any `/cards` route now answers `403 ENTITLEMENT_REQUIRED` where it
      previously succeeded. Ask OZAV to enable them; they then appear in
      `GET /capabilities`. Organizations created before this date keep what they
      already hold.

    - **2026-08-19 - `AssociatedPerson.verification.mode` can now be absent.**
      It used to be populated on every associated person. It is now inherited
      from the customer, and omitted when the customer has no recorded
      verification posture. Absent is not `full`: it means OZAV has no posture
      on record, and `status` must not be read as an attestation while `mode` is
      absent. Affects `POST`, `GET` and `PATCH` on
      `/customers/{customer_id}/associated-persons`. Same semantics the
      2026-08-18 entry below describes for `CustomerVerification.mode`.

    - **2026-08-18 - incomplete customer records no longer fail the call.**
      `GET /customers` and `GET /customers/{customer_id}` used to answer
      `500 CUSTOMER_MAPPING_FAILED` when any record lacked a canonical
      verification block, and on the list endpoint that failed the entire page.
      Such records now return `200` with `verification.mode`, `individual`,
      `business` and `metadata` omitted, and `requirements.missing_fields`
      naming what is still needed. **`verification.mode` was always populated in
      practice before this date**, so code that reads it without a presence
      check (`verification.mode.toUpperCase()`, `d["mode"]`) can now fail on
      records it never used to receive. The field was already optional in the
      schema; only the practice changed. See `CustomerVerification.mode`.

    ## Choose your integration

    Pick the profile that matches what you are building. Each lists what it is
    for, the resources it uses, and - honestly - what is callable **today**
    versus what is on the roadmap. Availability is per-organization; confirm
    yours with `GET /capabilities`.

    ### Full-stack neobank

    *Embed onboarding, compliance, FX, and wallets end-to-end.* Resources:
    Customers, Verifications, Screenings, Quotes, Transfers, Accounts, Payout
    Destinations, Wallets, Cards.

    **Today:** customer and verification **records** persist and read back, and
    screening and quoting are live. Sandbox verification simulation approves
    KYC and creates simulated wallet and BRL/Pix account projections for
    approved customers with complete BR data. BRL/Pix accounts, payout
    destinations, cards, and transfer paths are served. **Gated in
    live:** automatic wallet provisioning, live named-account opening, live card
    issuance, and full end-to-end settlement for every transfer path.

    ### Liquidity-only

    *Price and move money for customers you onboarded elsewhere.* Resources:
    Quotes, Transfers, Accounts, Payout Destinations.

    Quotes price supported corridors with real exchange conditions. Named fiat
    accounts and payout destinations are created through the same contract in
    every corridor OZAV serves; the sandbox returns deterministic instructions so
    you can build and test the full flow before moving value.

    ### Compliance-only

    *Screening and verification as a standalone service - no settlement.*
    Resources: Screenings, Verifications.

    **Today:** screenings and verifications are both live and complete
    end-to-end. Screen a person, business, or existing customer against
    designated lists and get a real result; start an identity verification and
    receive a hosted session URL to hand to the account holder, then poll the
    verification or receive the outcome on a webhook.

    ### Wallets

    *Read wallets and build unsigned transactions for custody use cases.*
    Resources: Wallets, Transaction Intents.

    Wallet read routes are tenant-scoped. They return real
    wallet records when custody provisioning already exists, but provisioning
    itself is still gated. **On the roadmap:** balances read empty until custody
    accounts exist, and unsigned-transaction building (transaction intents) is
    not yet callable.

    ## What's available today

    Endpoints are enabled per organization, and `GET /capabilities` is the
    source of truth for what **your** key can call.

    ### Getting a key

    Applications are open - you do not need an introduction to start.

    1. **Apply** at [ozav.io/caas/apply](https://ozav.io/caas/apply). No key
       required, and no sales call to get through the door.
    2. **We review it.** This step is a human at OZAV, not an automated
       approval - it is the compliance review, and it is deliberate.
    3. **Sandbox opens with the core products entitled.** Every organization is
       created with customer management, KYC/KYB, liquidity, and managed
       custody. You receive a portal invite, and from *Developers -> API Keys*
       you mint your own `ozav_sk_test_` keys, scoped how you choose, up to ten
       per environment. No one at OZAV is in that loop.

       Cards, KYC reliance, and self-custody wallets are **not** granted at
       creation. They are commercial products: ask OZAV to enable them and they
       appear in `GET /capabilities` for your organization.
    4. **Production is a separate, explicit activation.** Live `ozav_sk_live_`
       keys are issued by OZAV when your organization is activated. That gate
       is a risk decision and stays manual on purpose.

    So sandbox key management is fully self-serve once you are approved; only
    the initial review and the production activation involve us.

    | Tier | Resources | What it means |
    |---|---|---|
    | **Live** | Capabilities, Quotes, Screenings, Evidence Packs, Verifications, API Keys, Webhook Endpoints, Events, Customers, Documents, Transaction Intents, Wallet Transfers, Transfer Batches, Wallet Policy, Approvals, Accounts, Wallets, Self-custody Wallets, Deposit Addresses, Cards, Transfers, Unified Transactions, Payout Destinations, KYC Reliance, Yield, Swap | Serves real records against your entitlements. Where a specific corridor, rail or program is outside your entitlements, the request answers a named error rather than a partial result. |

    | **Coming soon** | Network Transfers, Contract Calls, Typed Messages, Client Tokens, Client Nft Collections | Defined and partly served: the read route returns real state, but the route that moves value is unreachable by construction. See below. |

    **Network Transfers** moves your own USDC between networks. The read route
    (`GET /network-transfers/{transfer_id}`) serves real state today. The route
    that performs the transfer is deliberately not callable: the write scope is
    absent from the only scope bootstrap, so no API key can hold it, and pricing
    is unconfigured - an absent price refuses rather than charging zero. Both
    are opened by a decision, not by a deploy.

    From 2026-08-16 to 2026-08-24 there was no "coming soon" row at all. A
    surface that is defined but not yet served gets its own row, with the
    reason - which is what Network Transfers does above.

    This table lists every surface `GET /capabilities` reports, and nothing
    else. A resource absent from all three tiers does not exist in v2.

    Every operation carries an `x-ozav-availability` marker (`live`, `beta`, or
    `coming_soon`) that matches its routed feature in `GET /capabilities`.

    ## Accounts and wallets are different things

    They are asked for separately, they hold different value, and they demand
    different evidence. Integrators conflate them constantly, so here is the
    contrast in one place.

    An **account** (`ozav_acc_`) is a named fiat receiving instruction. "Named"
    is the load-bearing word: the instruction carries your end customer's
    identity rather than being a shared OZAV address. That is why it needs
    identity - a bank credits a person, and the person has to be established
    before the instruction exists.

    How literal "named" is depends on the rail. On Brazilian rails the account
    is held in your end customer's own name at the institution. On the
    foreign-currency rails the holder of record is OZAV, and the account is
    segregated and attributed to your end customer - the same money separation,
    a different legal holder. If who holds the account at the institution
    matters to your contracts, treat those two as different products.

    Accounts are created with `POST /accounts`, which requires an `approved`
    customer and can still refuse: activation for the money paths is a further
    internal step with no public field (see "`approved` is not the finish
    line").

    A **wallet** (`ozav_wal_`) holds crypto. It comes in two kinds, and only one
    of them is ever created through the API:

    - **managed** - OZAV holds the key. It is provisioned as part of the
      customer when your organization has the custody product; you never request
      it directly.
    - **self-custody** - your end user holds the key and the address already
      exists. `POST /wallets` **records** it. Nothing is created, and OZAV never
      holds those funds.

    | | account | wallet |
    |---|---|---|
    | what it holds | fiat, at a bank | crypto, on a chain |
    | created by | `POST /accounts` | managed: with the customer. self-custody: `POST /wallets` |
    | needs identity? | yes - customer `approved`, and activation can still refuse | no - self-custody records an address |
    | needs an address? | no | self-custody: yes, and it must match the chain |

    Read `POST /wallets` for what recording an address does and does not prove.

    ⚠️ Recording a self-custody address needs no identity, but it does need the
    `self_custody` entitlement on your organization - a newly created
    organization does not get it by default and receives `403
    ENTITLEMENT_REQUIRED`. A customer that already holds a managed wallet is
    refused with `409 WALLET_ALREADY_MANAGED`: a customer is one or the other,
    never both.

    ## What each customer type must provide

    The required set is driven by the **country on the tax identity**, not by
    nationality. A Brazilian tax id (`cpf`, or `country: BR`) puts the customer
    on the Brazilian profile; anything else puts them on the base profile. Send
    `nationality` if you have it - it feeds risk assessment and will not change
    a single required field.

    `POST /customers` always requires `type`, `email`, `legal_name` and the tax
    identity for the type - and a tax identity is three parts, not one:
    `value`, `type` and `country`. Every address also carries `country`.

    The tables below are the **request fields**: send all of them and the
    customer is created and complete. They are not a transcription of
    `requirements.missing_fields`, which reports one flat token per concept from
    a closed list (`phone`, `residential_address`, `tax_id`, ...) and never a
    sub-path like `phone.country_code`. Read the tables to build the request;
    read `missing_fields` to find out what a customer you already created is
    still missing.

    #### Individual, tax id issued in Brazil

    | field | rule |
    |---|---|
    | `individual.legal_name` | full legal name |
    | `individual.tax_id.value` | the CPF; its check digit is verified |
    | `individual.mother_name` | full name |
    | `individual.date_of_birth` | ISO date |
    | `individual.phone.country_code` | dial code |
    | `individual.phone.number` | subscriber number |
    | `email` | top level, not inside the block |
    | `individual.residential_address.line1` | street |
    | `individual.residential_address.number` | required in Brazil |
    | `individual.residential_address.neighborhood` | required in Brazil |
    | `individual.residential_address.city` | non-blank |
    | `individual.residential_address.state` | required in Brazil |
    | `individual.residential_address.postal_code` | required in Brazil |
    | `individual.residential_address.country` | `BR`; required on every address |
    | `individual.source_of_funds` | declaration |
    | `individual.expected_monthly_volume_usd` | declaration |
    | `individual.monthly_income_usd` | declaration; the capacity the expected volume is measured against |
    | `individual.occupation` | profession |
    | `individual.nationality` | ISO 3166-1 alpha-2 |
    | `individual.is_pep` | politically-exposed-person declaration; `false` counts as answered |
    | `individual.account_purpose` | why the account is being opened |

    #### Individual, tax id issued outside Brazil

    | field | rule |
    |---|---|
    | `individual.legal_name` | full legal name |
    | `individual.tax_id.value` | `ssn`, `nif`, `passport` or `other`; no check digit is applied |
    | `email` | top level |
    | `individual.date_of_birth` | ISO date; every individual owes it, not only Brazilian ones |
    | `individual.phone.country_code` | dialling code, e.g. `+1`; a foreign-currency account cannot be opened without it |
    | `individual.phone.number` | national number |
    | `individual.residential_address.line1` | street |
    | `individual.residential_address.city` | non-blank |
    | `individual.residential_address.postal_code` | required for any account, not only Brazilian ones: the named-account holder registration refuses without it |
    | `individual.residential_address.country` | ISO 3166-1 alpha-2 |
    | `individual.phone.country_code` | dialling code, digits only, no `+` |
    | `individual.phone.number` | mobile; a landline is not accepted for the named-account holder record |
    | `individual.source_of_funds` | declaration |
    | `individual.expected_monthly_volume_usd` | declaration |
    | `individual.monthly_income_usd` | declaration; the capacity the expected volume is measured against |
    | `individual.occupation` | profession |
    | `individual.nationality` | ISO 3166-1 alpha-2 |
    | `individual.is_pep` | politically-exposed-person declaration; `false` counts as answered |
    | `individual.account_purpose` | why the account is being opened |

    #### Business, registration issued in Brazil

    | field | rule |
    |---|---|
    | `business.legal_name` | full legal name |
    | `business.registration.value` | the CNPJ; its check digit is verified |
    | `business.phone.country_code` | dial code |
    | `business.phone.number` | subscriber number |
    | `email` | top level |
    | `business.registered_address.line1` | street |
    | `business.registered_address.number` | required in Brazil |
    | `business.registered_address.neighborhood` | required in Brazil |
    | `business.registered_address.city` | non-blank |
    | `business.registered_address.state` | required in Brazil |
    | `business.registered_address.postal_code` | required in Brazil |
    | `business.registered_address.country` | `BR`; required on every address |
    | `business.company_type` | legal form |
    | `owners` | at least one associated person owning 25% or more |
    | `representative` | at least one associated person who can sign for the company (`director`, `officer` or `authorized_signer`). Separate from `owners`: a sole owner with only the `ubo` role does not satisfy it |
    | `business.source_of_funds` | declaration |
    | `business.expected_monthly_volume_usd` | declaration |
    | `business.annual_revenue_usd` | declaration; the capacity the expected volume is measured against |
    | `business.expected_daily_volume_usd` | declaration; not derived from the monthly figure |
    | `business.is_regulated_activity` | boolean; `false` counts as answered |
    | `business.nature_of_business` | what the company actually does |
    | `business.website` | public site or profile |
    | `business.account_purpose` | why the account is being opened |
    | `business.incorporation_date` | ISO date |

    #### Business, registration issued outside Brazil

    | field | rule |
    |---|---|
    | `business.legal_name` | full legal name |
    | `business.registration.value` | `ein`, `company_number` or `other`; no check digit is applied |
    | `email` | top level |
    | `business.phone.country_code` | dialling code, e.g. `+1`; a foreign-currency account cannot be opened without it |
    | `business.phone.number` | national number |
    | `owners` | at least one associated person owning **25% or more**. Corrected on 31 Aug 2026: only the Brazilian branch asked for this, so a foreign company could report `ready: true` having never declared a beneficial owner. The 25% floor is FATF, not a Brazilian rule |
    | `representative` | at least one associated person who can sign (`director`, `officer` or `authorized_signer`); without one the account holder stays pending at the bank and never receives coordinates |
    | `business.registered_address.line1` | street |
    | `business.registered_address.city` | non-blank |
    | `business.registered_address.postal_code` | required for any account, not only Brazilian ones: the named-account holder registration refuses without it |
    | `business.registered_address.country` | ISO 3166-1 alpha-2 |
    | `business.phone.country_code` | dialling code, digits only, no `+` |
    | `business.phone.number` | mobile; a landline is not accepted for the named-account holder record |
    | `business.source_of_funds` | declaration |
    | `business.expected_monthly_volume_usd` | declaration |
    | `business.annual_revenue_usd` | declaration; the capacity the expected volume is measured against |
    | `business.expected_daily_volume_usd` | declaration; not derived from the monthly figure |
    | `business.is_regulated_activity` | boolean; `false` counts as answered |
    | `business.nature_of_business` | what the company actually does |
    | `business.website` | public site or profile |
    | `business.account_purpose` | why the account is being opened |
    | `business.incorporation_date` | ISO date |

    #### Conditional: a business that declares a regulated activity

    The four tables above list what is asked of every customer of that shape.
    One requirement is conditional and therefore is not a row in them.

    When `business.is_regulated_activity` is `true`, two further fields become
    required and appear in `requirements.missing_fields`:

    | field | rule |
    |---|---|
    | `business.regulator` | name of the supervising authority |
    | `business.regulated_activity_details` | licence or scope of the regulated activity |

    They are asked for **only** on a `true` answer. `false` is an answer - the
    company has declared it is not regulated, and asking who supervises it would
    be asking for the detail of something it denied. An **absent**
    `is_regulated_activity` is an open question: `missing_fields` asks for the
    question itself and not for its follow-up, so you are never asked to name a
    regulator for an activity you never said was regulated.

    #### Opening an account in USD, EUR or GBP

    🔴 **Read this before you build against it.** `sepa`, `swift` and
    `faster_payments` are **live behind enablement** - the corridor is served in
    production, but opening an account for a given customer also requires that
    OZAV has enabled that customer for foreign-currency named accounts. A `live`
    request for a customer who is not enabled is refused with a named error that
    says so.

    `fedwire` stays `sandbox only`: no US-domiciled account has ever been issued
    on this corridor. A USD account is issued as a **GB IBAN** and reached by
    `swift` - that is the shape the provider returns, and the one to build
    against.

    `Rail.x-ozav-rail-availability` is the authoritative table; this paragraph
    describes it and is checked against it.

    The field list below is published so you can build the request now and not
    change it later - it is what the holder record asks for. It is not a
    statement that the account opens on a `live` key today. Availability is
    answered by that table, never by this section.

    **Business customers today, and the reason is ours, not the bank's.** A
    foreign-currency account is opened against a holder record filed as a
    company, so `type: individual` is refused there for now. Corrected on 31 Aug
    2026: an earlier version of this page said there was "no individual path"
    and that an individual "will not hold" such an account even once the
    corridors open. That read as a permanent product rule and it is not one -
    the institution behind the corridor does document individual holders. What
    is missing is OZAV's own branch for them.

    The refusal stays until that branch exists, and it is the right refusal:
    opening one today would file a natural person as a company, with their
    personal tax id standing in for both the company's tax id and its
    registration number. A wrong filing at the institution is worse for the
    customer than a clear "not yet".

    An individual can still be onboarded, verified, and hold a wallet, and BRL
    corridors are unaffected by this paragraph.

    🔴 **And the same is true of BRL/Pix today - which this page did not say,
    and the sandbox does not enforce.** No Brazilian Pix provider currently
    opens a named account for an individual: all five declare
    `supportsIndividualAccount: false`. A `live` key asking for a BRL named
    account with `type: individual` is refused with `422
    ACCOUNT_HOLDER_TYPE_NOT_SUPPORTED`.

    ⚠️ **A `test` key opens it anyway.** The sandbox provisioner does not read
    the holder type, so it answers `201` for exactly the request `live`
    refuses. Measured on 31 Aug 2026: **57 sandbox BRL accounts across 12
    integrators** were opened for individual holders, the most recent that same
    day. If you are building an individual BRL flow against a test key, it will
    not survive the switch to a live key - and the sandbox will not tell you.

    This is a limitation of OZAV's provider coverage, not of your request, and
    it is expected to change: when a Pix provider that opens individual
    accounts is integrated, the refusal goes away. Until then, build BRL named
    accounts for `type: business`.

    ✅ **A company registered outside Brazil can hold one.** `registration.type`
    may be `ein`, `company_number` or `other`, and the value is sent as declared
    - no Brazilian format is imposed on a foreign registration, and
    `registration.country` is what determines the country of record. Until 20 Aug
    2026 this was refused: the holder record stamped every business as a
    14-digit CNPJ, so a foreign registration failed a Brazilian check nobody had
    decided to apply to it.

    ⚠️ The reverse is now stated explicitly rather than implied: an account on a
    **Brazilian** rail does need a Brazilian tax identity. A customer whose
    registration is an `ein` is refused there with
    `422 ACCOUNT_HOLDER_TYPE_NOT_SUPPORTED`, naming the declared type - the
    customer is valid, only that corridor needs a CPF or CNPJ.

    Registering that holder needs a slightly fuller set than onboarding alone,
    and the difference is the part integrators hit late:

    | field | note |
    |---|---|
    | `email` | top level |
    | `business.phone.number` | the subscriber number |
    | `business.phone.country_code` | dial code, e.g. `55` |
    | address `line1` | street |
    | address `city` | non-blank |
    | address `postal_code` | **required here**, and onboarding does not ask for it |
    | address `country` | **required here**, ISO 3166-1 alpha-2 |
    | address `state` | optional |

    🔴 **And one authorised person, by name.** The holder record is not complete
    without a natural person attached to it - the individual who controls or
    signs for the company. Measured on 21 Aug 2026 against the two live holders:
    the one with a person attached is active and has receiving coordinates; the
    one without has none, and does not acquire them over time. An account opened
    for a holder with no person attached is a bank account that never receives.

    | field | note |
    |---|---|
    | person `firstName` | given name, as on the identity document |
    | person `lastName` | family name |
    | | 🔴 **This is why `legal_name` on an associated person must be at least two words.** OZAV splits it into given and family name; a single-word name cannot be split, and repeating the same word in both would invent a surname on the bank's record. A person whose name will not split is skipped when OZAV picks who signs - and if they were the only signer, the holder is registered with nobody attached, stays pending at the bank, and the account never receives coordinates. `requirements` reports it as `individual.legal_name`, the same token as a missing name, because the fix is the same: correct the name. |
    | person `birthDate` | ISO date, `YYYY-MM-DD` |
    | person `roles` | at least one of `BeneficialOwner`, `AuthorizedPerson`, `Director` |
    | person `ownershipPercent` | only when the person is an owner; omitted otherwise |

    ⚠️ `ownershipPercent` is sent **only** for a person declared as an owner.
    Declaring a stake for someone who does not hold one is a false ownership
    statement, and OZAV will not make it on your behalf.

    ⚠️ **`postal_code` and a structured `phone` are the two that catch people
    out.** Outside Brazil, `requirements.missing_fields` asks only for
    `line1`, `city` and `country` on the address, and it does not ask for a
    postal code at all - that set is enough to complete onboarding, and not
    enough to open this account. Send both up front and you will not have to go
    back to your customer for them later.

    **Ownership.** This account can only be opened for a holder with a single
    controlling owner, and OZAV has to be able to establish that it is one. A
    company whose ownership is split across several qualifying owners - or whose
    ownership OZAV cannot resolve - is refused rather than filed under a
    declaration that does not describe it. If that is your case, talk to us
    before integrating.

    ✅ **These refusals name the customer, not the corridor.** They arrive as
    `422 ACCOUNT_HOLDER_TYPE_NOT_SUPPORTED`, which is a different code from
    `ACCOUNT_RAIL_NOT_AVAILABLE` on purpose: requesting a different currency or
    rail will not change the answer, because the answer is about who the holder
    is. Until 20 Aug 2026 both refusals shared the corridor code, so an
    integrator reading an older response may have concluded the currency was
    unsupported when it was not.

    🔴 **Approval is not instant, and the wait has its own code.** Once the
    holder is registered, the provider's compliance team reviews them before the
    account can receive anything; they state 24-48 hours. A `POST /accounts`
    during that window returns `409 ACCOUNT_HOLDER_PENDING_APPROVAL`. Nothing in
    your request is wrong and there is no field to correct - send the same
    request again once the review clears. Until 31 Aug 2026 this state came back
    as `500 ACCOUNT_MAPPING_FAILED`, telling you OZAV had broken when nothing
    had; an integrator reading an older response may have opened a ticket for a
    customer who was simply still in review.

    ⚠️ Do not poll it in a tight loop. The code is the same until the review
    finishes, so retrying every few seconds only produces the same answer.

    🔴 **Documents are required here too - an earlier version of this page said
    they were not, and that was wrong.** The `POST /accounts` call itself carries
    structured data only, with no file attached. That is a fact about the call,
    not about the requirement, and stating the first as if it settled the second
    is the mistake this paragraph is correcting.

    Measured on 21 Aug 2026 against the two live holders at the institution: the
    one with a company dossier attached is active and has receiving
    coordinates; the one with none has stayed pending since it was created, and
    does not acquire them over time. **A holder with no dossier is a bank
    account that never receives.**

    The dossier is the same list as for a Brazilian account - the six below,
    plus an identity document for each associated person. Send it once; it is
    not collected twice because a second currency was opened later.

    ⚠️ **Sending the dossier does not, by itself, make the account receive.** The
    documents are what a compliance review needs in order to run; the review
    itself happens at the institution, on its own clock, and the account becomes
    payable when that review clears - not when the upload succeeds. Measured on
    21 Aug 2026: the holder that receives shows its sanctions and know-your-
    business checks cleared; the one that does not shows a record still awaiting
    that review, unchanged for weeks.

    So: a complete dossier is the part you and OZAV control, and it is a
    precondition, not a switch. Plan the go-live of a foreign-currency corridor
    around a review you do not schedule, and do not quote your own customer a
    date that depends on it.

    #### Which documents, for whom, and for which account

    Two mechanisms establish identity. An individual uses only the first; a
    business uses both.

    | | how identity is established | files you upload |
    |---|---|---|
    | individual | a hosted verification session captures the document, liveness and face match (`POST /verifications`) | none |
    | business | hosted session for KYB/UBO, **plus** the company dossier you upload (`POST /customers/{customer_id}/documents`) | the list below |

    An individual is NOT asked for file uploads. Start a verification instead
    and the session collects the identity artifacts directly from the person -
    that is what `kyc_document`, `liveness` and `face_match` mean in
    `CapabilityKind`. Uploading files for an individual is neither required nor
    a substitute for the session.

    🔴 **Corrected on 31 Aug 2026 - this paragraph used to say a business that
    only ever holds foreign currency "is not asked for the six".** That was
    true of the implementation and wrong as a rule: the requirement was wired to
    a Brazilian rail's readiness flag, so a US or Portuguese company was asked
    for 2 documents where a Brazilian one was asked for 6, and `ready: true`
    quietly meant different things in different jurisdictions.

    **Proof of address and a bank statement are now asked of every business**,
    whatever currency it holds. They are KYB, not a property of a payment rail.
    A Brazilian company is additionally asked for the rail's own list, so the
    Brazilian set is still the larger one - but the difference is now a rail
    requirement that is named, not an accident of which flag was read.

    **A tax return is not required of anyone** - not a business, not an
    individual, at any volume. What OZAV needs is economic capacity, and that is
    already a required FIELD (`annual_revenue_usd` for a business,
    `monthly_income_usd` for an individual). A tax return varies by jurisdiction
    and much of the world has no direct equivalent; the structured field answers
    the same question and compares across borders.

    Both sets are collected the same way, and OZAV keeps one canonical record:
    nothing you already sent has to be sent again because a rail was chosen
    later. A Brazilian account chosen later is still the first time the rail's
    extra documents are asked for - plan for that if BRL is anywhere on your
    roadmap, because collecting from your customer after the fact is exactly the
    round trip this section exists to prevent.

    #### Supporting documents a business must upload

    ⚠️ **Uploading a document is TWO calls, and the first one alone does not
    count.** `POST /customers/{customer_id}/documents` registers the document and
    returns an `upload_url`; the record sits at `pending_upload`, which is
    treated as **not sent**. The bytes go in the second call, and only then does
    the record become `received` - the state the account gate accepts.

    A `201` from the first call is not a delivered document. If you stop there,
    the dossier looks complete on your side and is empty on ours, and the account
    is refused for a document you believe you sent. (There are records sitting at
    `pending_upload` in production today for exactly this reason.)

    `requirements.missing_fields` covers request FIELDS. Documents are uploaded
    through `POST /customers/{customer_id}/documents` **plus the upload call**,
    and are not reported there, so they are listed here instead - opening a Brazilian account asks
    for all six, plus an identity document per associated person, before it will
    proceed. A `test` key does not enforce this: the sandbox opens the account
    with no documents at all, so sandbox success is not evidence the dossier is
    complete.

    ⚠️ **Documents are no longer a Brazilian-only requirement (11-Sep-2026).**
    Opening a `live` account in USD, EUR or GBP used to check contact details
    only and asked for no document at all. It now applies the same gate, with the
    same `422 ACCOUNT_PROFILE_INCOMPLETE` and the same `missing` list, entries
    prefixed `documents.`:

    | holder | documents required to open a non-BRL account |
    |---|---|
    | individual | `identity_document`, `proof_of_address`, `bank_statement` |
    | business | `company_registration`, `ownership_structure`, `proof_of_address`, `bank_statement` |

    A Brazilian account still asks for all six, because the BRL rail adds
    `financial_statement` and `source_of_funds` on top. You do not have to
    reconcile the two lists yourself: `GET /customers/{customer_id}/documents`
    returns `still_required` for that customer, and it is computed by the same
    code the account gate runs - what it names is exactly what the gate will ask
    for.

    | `doc_type` to send | what it establishes |
    |---|---|
    | `company_registration` | the company exists and is currently registered |
    | `ownership_structure` | who ultimately owns it |
    | `proof_of_address` | where it operates from |
    | `financial_statement` | its financial position |
    | `bank_statement` | recent account activity |
    | `source_of_funds` | where the money comes from |

    These are the literal `doc_type` values - any other string is rejected with
    `400`. **One `bank_statement` counts for two of them**: it satisfies both the
    financial position and the recent activity, so five uploads can cover all
    six requirements.

    A business also needs at least one associated person whose `roles` include
    `authorized_signer`, `officer` or `director`, in addition to the ≥25% owner
    that `missing_fields` reports as `owners` (role `ubo`). They can be the same
    person, and `roles` takes more than one value.

    ⚠️ **Each of those people needs an identity document of their own.** Send it
    as `identity_document` - it is not covered by any of the six company
    documents above, and it is the one integrators discover last, after the
    dossier is already assembled. A person OZAV has already approved does not
    need it re-sent.

    ✅ **You do not have to guess: `POST /accounts` tells you.** When the dossier
    is not yet complete the call is refused with `422
    ACCOUNT_PROFILE_INCOMPLETE`, and the error carries a `missing` list naming
    every field and document still needed, down to address components such as
    `business.registered_address.postal_code`. Once you have declared a person
    who is a ≥25% owner or an authorised signer, it also reports what is missing
    for that person specifically - `associated_persons[0].cpf`,
    `associated_persons[0].identity_document_or_approval`. Before that it asks
    for the person itself (`associated_persons.ubo_25_plus`), so expect the list
    to get longer once you add one. That list is the
    authoritative checklist for opening a Brazilian account, and it is
    deliberately longer than `requirements.missing_fields` on the customer:
    `missing_fields` answers "is this customer complete?", the `422` answers "can
    this customer be given an account?" Drive your onboarding UI off the `422`
    when the customer already exists, and off the tables above when you are still
    building the create request.

    The categories above are a DECLARATION vocabulary, not a list of which
    artifact satisfies which category in a given country: whether a specific
    national registry extract or utility bill is accepted is decided in review,
    and OZAV does not publish a per-jurisdiction acceptance table.

    **`approved` is not the finish line.** A customer can pass its checks and
    still be refused on the money paths, because OZAV activates a customer only
    once it also holds a payable identity. Today that means an account in
    Brazilian rails needs a CPF or CNPJ, so a customer whose tax identity is a
    passport can be created and verified but cannot yet be given a named
    Brazilian account.

    ⚠️ **There is no public field for that last step.** `status` goes no further
    than `approved` - the values are `pending`, `in_review`, `approved`,
    `rejected` and `suspended`, and nothing else is ever returned. Do not poll
    for a state that will not arrive. The way you find out is `POST /accounts`:
    it either opens the account or refuses with a coded error you can act on.

    ## Jurisdictions we serve

    Eligibility is decided by **declared residence and place of registration**,
    never by nationality alone.

    Customers whose tax identity or address is issued in one of these
    jurisdictions are refused at creation, with `422 JURISDICTION_NOT_SERVED`
    naming the field that caused it:

    | refused |
    |---|
    | `AF` Afghanistan, `BY` Belarus, `CU` Cuba, `IR` Iran, `KP` North Korea, `MM` Myanmar, `RU` Russia, `SD` Sudan, `SS` South Sudan, `SY` Syria, `ZW` Zimbabwe |

    These are accepted and carry enhanced due diligence. Expect longer review
    and requests for further evidence:

    | enhanced due diligence |
    |---|
    | `AL` `BB` `BF` `CD` `CM` `GH` `HT` `JM` `JO` `ML` `MZ` `NG` `NI` `PA` `PH` `PK` `SN` `TT` `TZ` `UG` `VE` `VN` `YE` |

    Everywhere else is served, subject to name screening against the
    consolidated UN, OFAC, UK and EU lists and to a risk-based decision. Being
    outside both tables means you may proceed; it does not by itself guarantee
    acceptance.

    ## Quickstarts

    Start with `GET /capabilities` to confirm the entitlements and scopes on
    your key, then follow the flow that matches your integration:

    - **Compliance-only:** create or reuse a customer, create a verification or
      screening, and read the resulting compliance status. In sandbox, use
      **KYC simulate** with `POST /verifications/{id}/simulate` to test approval
      and rejection callbacks.
    - **Neobank sandbox:** create a customer, simulate KYC approval, then call
      `GET /wallets?customer_id=...` and `GET /accounts?customer_id=...` to
      read the simulated wallet and BRL/Pix account instructions.
    - **Liquidity quote-only:** create or reuse a customer and call
      `POST /quotes` to price a supported corridor before attempting any
      money-moving flow.
    - **Webhooks and events:** create a webhook endpoint, send a test delivery,
      and reconcile deliveries with `GET /events`.

    Sandbox keys let you exercise these flows without moving real funds. Live
    keys persist production records, but money movement and live card issuance
    remain gated by your organization's enabled rails.

    ## Authentication

    Authenticate every request with your secret key in the **`X-API-Key`**
    header (or as an `Authorization: Bearer` token - the same key, either way).

    ```
    X-API-Key: ozav_sk_test_5f8d…    # sandbox
    X-API-Key: ozav_sk_live_9a1c…    # production
    ```

    - **The key prefix selects the environment.** `ozav_sk_test_` keys resolve
      against the sandbox; `ozav_sk_live_` keys resolve against production. The
      base URL is identical for both - there are no separate hostnames and no
      code changes to promote from test to live.
    - **Two authorization axes.** Your organization holds **entitlements** (the
      products enabled for you: `liquidity`, `compliance`, `customers`, `cards`,
      `custody`, `self_custody`, `kyc_reliance`). Each key additionally carries
      **scopes** (e.g. `quotes:write`, `transfers:read`) that restrict what
      that key may do. Effective permission is the intersection. A missing
      product returns `403 ENTITLEMENT_REQUIRED` ("talk to us"); a missing
      scope returns `403 INSUFFICIENT_SCOPE` ("reissue the key with that
      scope").
    - **Getting keys.** Mint, list, rotate, and revoke keys under the API Keys
      resource, or in the developer portal. A new key's secret is shown **once**
      at creation - store it securely. Rotate with an overlap window so the old
      secret keeps working while you cut over.
    - **Keep secrets server-side.** A live secret key can move money. Never ship
      it in a browser, a mobile binary, or a public repository.

    ## Sandbox & Environments

    There is one base URL - `https://developers.ozav.io/v2` - for both
    environments; the key prefix does the routing.

    It is `developers.`, not `api.`. `api.ozav.io` resolves and answers, but it
    is not this API: it returns `{"error":"requested path is invalid"}`, a shape
    that appears nowhere in this document and carries no `request_id`. If you
    get an error whose body does not match the `Error` schema below, check the
    hostname before checking anything else.

    | | Test | Live |
    |---|---|---|
    | Key prefix | `ozav_sk_test_` | `ozav_sk_live_` |
    | Moves real money | No | Yes |
    | Data | Isolated sandbox | Production |

    A `test` key exercises customers, verifications, accounts, quotes,
    transfers and screenings with no real settlement and no real bank-account
    opening. When you are ready, swap the key for its `live_` counterpart; your
    CODE does not change.

    **Your DATA does not carry over, and this is the part that surprises people.**
    Every resource belongs to exactly one environment. A customer created with a
    `test` key exists only in test; a customer created with a `live` key exists
    only in live. **The ids are different, and an id from one environment is not
    found in the other** - `GET /customers/{id}` with the wrong environment's key
    answers `404 CUSTOMER_NOT_FOUND`, and `GET /customers` lists only that
    environment's records. A brand new `test` key therefore lists **zero**
    customers, even for an organization with many customers in production. That
    is not an error and not a permission problem: there is simply nothing in test
    yet.

    So the first thing to do with a `test` key is **create your own test data**
    with it: `POST /customers`, then accounts, wallets, quotes. Do not reuse
    production ids. When you go live, the same code runs against the `live` key
    and operates on the live records it creates there.

    ### What the sandbox cannot exercise on its own

    Read this before you plan a schedule around it. Each row below is a place
    where the resource is created but the flow cannot be finished with a
    self-serve `test` key, and each needs OZAV to act on your account. None of
    them is a bug you can work around by changing your request.

    | You want to | What happens | What unblocks it |
    |---|---|---|
    | Create a `full` customer and then verify it | The sandbox create already answers `status: approved` for a `full` customer (the simulated settlement path reports it active), so a flow tested only here never reaches `POST /verifications`. A `reliance` customer is `pending` in both environments until profile + partner document + screening are in | Nothing to unblock - a warning: on `live` a `full` create answers `pending` and only a verification advances it. Exercise `POST /verifications` + `/simulate` in sandbox anyway, so the step exists in your code. |
    | Create a customer that settles | Works, but the sandbox does not exercise the terms gate: the attestation is simulated, and a missing, unknown or foreign `terms_session_id` is accepted with `201` | Nothing to unblock, and one thing to know: on `live` the same call is refused with `422 TERMS_ACCEPTANCE_REQUIRED` unless you pass an accepted session. Collect it with `POST /customers/terms-sessions` and `POST /customers/terms-sessions/accept` (both public, since 2 September 2026), and pass the id on `POST /customers`. Build that into your flow now; the sandbox will not remind you. |
    | Render the Pix payment screen | Works, and this row said the opposite until 3 Sep 2026. The sandbox does NOT confirm the pay-in before responding: it holds the transfer in `awaiting_payment` and returns `source.payment_instructions` carrying a simulated BR Code, a real EMV MPM payload with a valid CRC that a genuine Pix library parses | Nothing to unblock. To move past the payment, call `POST /transfers/{transfer_id}/simulate`: it confirms the pay-in the way a real payer would and advances the transfer through both legs to a terminal `completed`, emitting `transfer.updated` and then `transfer.completed`. Until 8 Sep 2026 it advanced only the pay-in leg and the transfer stopped at `payment_confirmed` with `destination_leg: pending` forever - an integrator asked whether the crypto leg settled asynchronously, and the honest answer was that nothing settled it. |
    | Pay out to a Pix key (off-ramp) | Works end to end: the sandbox executor pays the destination, the transfer lands `completed` and `transfer.completed` fires. Until 4 Sep 2026 every sandbox Pix off-ramp died on `422 PROVISIONING_REQUIRED`, because a Pix-key destination carries no venue reference and there is no venue in the sandbox | Nothing to unblock. One thing to know for `live`: there, a Pix-key destination becomes payable only after we register it with the payout venue; read `capabilities.receive` on the destination (true on a sandbox Pix destination, false on a live one until registered), never `status`. |
    | Test a card authorization | Every `/cards` route answers `403 ENTITLEMENT_REQUIRED` for a new organization, which no longer receives the `cards` product at creation | Ask OZAV to grant the `cards` product to your organization. After that, the card is `active` and every purchase declines with `Insufficient balance`, because no endpoint loads a prepaid balance. See `funding_type` on `Card`. |
    | Send a user to hosted verification | A `test` key never starts a hosted session (a real one bills a third party per attempt), so `session_url` is absent | Drive the outcome with `POST /verifications/{id}/simulate`, which is the sandbox affordance. On `live`, also ask for a hosted capability and ask OZAV to enable hosted capture; see `session_url`. |
    | Open a named account for a holder | The sandbox runs the SAME profile gate as `live`, only shallower, and this row used to read as though it ran none. It still requires the customer to be approved, still applies the rail matrix, and still requires a document that passes its own check digit (11 digits for CPF, 14 for CNPJ) plus a holder name, refusing with the same `422 ACCOUNT_PROFILE_INCOMPLETE` you would get on `live`. What it skips is everything the provider asks for beyond that (address, phone, mother's name, the KYB shareholder packet, uploaded files), so it opens with **no supporting documents at all** | Nothing to unblock: this one is a warning, not a limitation. A sandbox account opening proves your request is well-formed and the holder's core identifiers are present, never that the holder is ready. Send the documents with `POST /customers/{customer_id}/documents` first; the `422 ACCOUNT_PROFILE_INCOMPLETE` names every field and document still missing, and it is the authoritative checklist. 🔴 **But one refusal is NOT on that checklist, and no document clears it:** a BRL named account for `type: individual` refuses on `live` with `422 ACCOUNT_HOLDER_TYPE_NOT_SUPPORTED`, because no Brazilian Pix provider opens one today - and the sandbox opens it anyway. Measured on 31 Aug 2026: 57 such sandbox accounts across 12 integrators. If your flow is individual + BRL, sending more documents will not change the answer; see the named-account section. |
    | Read a wallet balance | `GET /wallets/{wallet_id}/balances` in `test` starts every wallet at a round 10,000.00 USDC and USDT and then moves with the transfers that COMPLETED on it - an on-ramp credits the amount delivered, an off-ramp debits the amount sent. Until 8 Sep 2026 it was a frozen constant, so a credit, a withdrawal and a no-op were indistinguishable | Nothing to unblock, and three things to know. It is a derived figure, not a ledger: it does not reflect crypto sent to the wallet from outside, nor card spend. `GET /wallets/{wallet_id}/deposit-addresses` and `/onchain-balances` stay EMPTY in `test`, because a sandbox wallet has no chain addresses. And the opening 10,000 is deliberately round: nobody should mistake it for money. |
    | Build a self-custody transaction intent | Every sandbox wallet is `managed` by default | Ask OZAV to grant the `self_custody` product to your sandbox organization, then send a `self_custody` block on `POST /customers`. With both products enabled, pick per customer with `custody_model`; omitting it defaults to `self_custody`. |

    Everything else in the list above the table does work end to end, including
    a full BRL on-ramp transfer, screening against designated lists with no
    customer record, document upload, and webhook delivery with signature
    verification.

    That first item was FALSE until 8 September 2026 and is worth naming, since
    a sentence like it is exactly what an integrator plans a schedule around. A
    sandbox BRL on-ramp could not reach `completed`: `POST
    /transfers/{transfer_id}/simulate` advanced only the pay-in leg, an on-ramp
    has no payout leg to dispatch, and the machine that credits a wallet in
    production is driven by a real Pix charge that the sandbox never creates.
    Measured in production on that date: none of the 54 sandbox on-ramps ever
    left `payment_confirmed`, the oldest dating from 17 August. It is true now.

    ## Idempotency

    Money-moving and creation endpoints accept an **`Idempotency-Key`** header
    so a retry can never execute twice.

    - **Format:** 16-256 characters of `[A-Za-z0-9_-]`. Generate a fresh key
      per logical operation (a UUID works well) and reuse it only when retrying
      that exact operation.
    - **Required vs optional.** `POST /transfers` **requires** it - a transfer
      without one is rejected. `POST /customers` and `POST /payout-destinations`
      accept it (optional, recommended). Reads never need one.
    - 🔴 **`POST /accounts` is the exception, and it does not read the header at
      all.** Sending `Idempotency-Key` there is not an error and not a
      protection: none of the mechanics below apply to it - no
      `Idempotent-Replayed`, no `409 IDEMPOTENCY_KEY_REUSED` on a changed body,
      no in-flight `202`. This paragraph exists because the sentence above says
      "creation endpoints" and an integrator would reasonably include it.

      What protects that endpoint instead is the **natural key**: an account is
      unique per `(customer, currency, country, rail)`. Repeat the same request
      and you get **`200`** with the account that already exists, where the
      first call returned **`201`** - so the status code, not a header, is how
      you tell a fresh account from an existing one. You do not need to generate
      a key for it, and reusing one across different corridors will not confuse
      it.
    - **Replay.** A retry with the **same key and same body** replays the
      original response verbatim - same status code - plus the header
      **`Idempotent-Replayed: true`**, so you can tell a replay from a fresh
      call.
    - **In flight.** If the first request is still executing when the retry
      arrives, the retry returns **`202`** with `{"status":"processing"}` -
      wait, then retry with the same key to receive the stored result.
    - **A `202` that does not resolve means the outcome is unknown, not
      pending.** If the original request failed after the resource may already
      have been written, the key stays claimed and every retry with it answers
      `202` - there is no stored result coming. Do not loop. Reconcile with a
      `GET` (list by `customer_id`, or read the id you were given) to find out
      whether the resource exists, then continue with a **new** key if it does
      not. This is deliberate: a claimed key that outlives its request is
      recoverable, a duplicate customer or payout destination is not.
    - **Conflicts.** The same key with a **different body** is a client bug and
      returns **`409 IDEMPOTENCY_KEY_REUSED`** - it never overwrites the first
      result. Use a new key when the request has genuinely changed.

    Retry idempotency-safe on `429` and `5xx` with the same key and exponential
    backoff; treat a `4xx` other than `409` as terminal. Cap the retries: if
    the response becomes a `202` that repeats, stop and reconcile as described
    above rather than retrying indefinitely.

    ## Pagination

    List endpoints are cursor-paginated and uniform.

    - **`limit`** - page size, `1`-`100`, default `20`.
    - **`starting_after`** - an opaque cursor; pass the previous page's
      `next_cursor`.

    Every list responds with the same envelope:

    ```json
    {
      "data": [ /* … */ ],
      "has_more": true,
      "next_cursor": "b3phdl9jdXNfMDFK…"
    }
    ```

    Loop until `has_more` is `false`. Cursors are opaque - do not parse or
    construct them; a malformed cursor fails loud with `400 INVALID_CURSOR`
    rather than silently resetting to page one.

    ## Amounts & Currencies

    - **Amounts are decimal strings**, never floats: `"1250.00"`, `"0.50"`.
      This preserves precision across currencies - the float `0.1` cannot
      represent one tenth exactly, and money math must not drift.
    - **Precision.** Quote and balance amounts carry up to **6** fractional
      digits; the amount you submit when creating a transfer accepts up to
      **8**.
    - **Currency codes** are uppercase ISO 4217 (`BRL`, `USD`, `EUR`, `MXN`) or
      stablecoin symbols (`USDC`, `USDT`). Codes are accepted case-insensitively
      on input and always returned uppercase.
    - **Exchange conditions are a spread in integer basis points** - `150`
      means 150 bps (1.50%), stored as an integer and divided by 10,000 in rate
      math. Never a float or a percentage string. A quote's public
      `exchange_conditions.spread_bps` is only the additional spread selected
      by the client for that quote. OZAV cost, floor, and margin analytics stay
      internal.

    ## Errors

    Every error returns the same JSON envelope alongside the HTTP status:

    ```json
    {
      "error": {
        "type": "invalid_request_error",
        "code": "MISSING_FIELD",
        "message": "email is required.",
        "param": "email",
        "request_id": "req_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
        "details": [
          { "code": "MISSING_FIELD", "message": "email is required.", "param": "email" }
        ]
      }
    }
    ```

    - **`type`** is one of eight stable classes:

      | `type` | Meaning | Typical status |
      |---|---|---|
      | `invalid_request_error` | Malformed or unfulfillable input - a missing field, a bad value, or a well-formed request that cannot be executed. | `400`, `422` |
      | `authentication_error` | Missing, invalid, revoked, or expired key. | `401` |
      | `permission_error` | Authenticated but not permitted - product not enabled, or the key lacks the scope. | `403` |
      | `not_found_error` | Resource does not exist or is not visible to the caller. | `404` |
      | `conflict_error` | Conflicts with existing state - e.g. an idempotency key reused with a new body, or a duplicate customer. | `409` |
      | `rate_limit_error` | Too many requests - honor `Retry-After`. | `429` |
      | `idempotency_error` | The idempotency layer could not be reached; retry the same request. | `503` |
      | `api_error` | An unexpected server-side or dependency error; OZAV fails closed rather than guess. | `500`, `503` |

    - **`code`** is a stable, machine-readable string you can branch on
      (`ENTITLEMENT_REQUIRED`, `INSUFFICIENT_SCOPE`, `IDEMPOTENCY_KEY_REUSED`,
      `RAIL_NOT_AVAILABLE`, `QUOTE_EXPIRED`, …).
    - **`param`** names the offending field when applicable; **`details`** may
      carry per-field errors.
    - **`request_id`** identifies the exact call - include it in every support
      request.

    The complete, machine-readable catalog of `code` values ships alongside
    this reference as `v2-error-codes.json`, so you can generate typed error
    handling directly from it.

    ### Resource ids, and what a malformed one returns

    The resource ids that appear in a path position carry these prefixes
    (other published ids, such as `ozav_q_` for quotes, exist but are not used
    in a path position and are not listed here):

    | prefix | resource |
    |---|---|
    | `ozav_cus_` | customer |
    | `ozav_ap_` | associated person |
    | `ozav_pd_` | payout destination |
    | `ozav_tr_` | transfer |
    | `ozav_acc_` | account |
    | `ozav_wal_` | wallet |
    | `ozav_card_` | card |
    | `ozav_key_` | API key |
    | `ozav_scr_` | screening |
    | `ozav_vrf_` | verification |
    | `ozav_wh_` | webhook endpoint |
    | `ozav_txi_` | transaction intent |
    | `ozav_apr_` | approval |
    | `ozav_wtb_` | transfer batch |
    | `ozav_tok_` | client token |
    | `ozav_nft_` | client NFT collection |

    **An id whose prefix does not match the position it was used in returns
    `404`, not `400`.** The request never reaches a lookup - the id could not
    name a resource of that type - but the status is `404` so that one status
    covers every "you cannot have this resource" case. Branch on `code` and
    read `message`, not on the status, to tell the cases apart.

    The message names the prefix that position expects, and, when the id
    carries a prefix from the table above, names which resource it belongs to
    instead. Sending a `ozav_cus_…` where a wallet id belongs tells you exactly
    that.

    **What `404` never tells you is whether a resource exists.** A well-formed
    id belonging to another account, to the other environment, or to nothing at
    all all return the same generic phrase, indistinguishable from one another.
    Only the malformed case gets the extra sentence, because that sentence is a
    statement about the text you just sent - not about our records.

    ## Networks & Assets

    `components.schemas.Network` is the public v2 wire enum today. OZAV accepts
    only `USDC` and `USDT` as blockchain assets on this surface today. Other
    stablecoins such as DAI, USDB, PYUSD, and EURC are not accepted by public
    API v2 flows unless this table and `GET /capabilities` explicitly say so.

    | Network | Public v2 status | Custody stack status | Accepted assets today |
    |---|---|---|---|
    | `ethereum` | accepted | active | USDC, USDT |
    | `base` | accepted | active | USDC |
    | `optimism` | accepted | active | USDC |
    | `polygon` | accepted | active | USDC, USDT |
    | `avalanche_c_chain` | accepted | active | USDC, USDT |
    | `worldchain` | accepted | active | USDC |
    | `unichain` | accepted | active | USDC |
    | `linea` | accepted | active | USDC |
    | `sonic` | accepted | active | USDC |
    | `sei` | accepted | active | USDC |
    | `celo` | accepted | active | USDC |
    | `zksync_era` | accepted | active | USDC |
    | `solana` | accepted | active | USDC, USDT |
    | `tron` | accepted | active | USDT |
    | `stellar` | accepted | active | USDC |
    | `tempo` | not accepted | deferred | none |
    | `arbitrum` | not accepted | active | USDC, USDT |
    | `aptos` | not accepted | deferred | none |
    | `sui` | not accepted | deferred | none |

    Active custody stack status does not mean a public end-to-end flow is live.
    Wallet provisioning, live card issuance, live BRL/Pix account opening,
    managed wallet sends, and self-custody transaction building remain gated unless
    `GET /capabilities` says your organization can use them.

    ## Integration Guides

    The same endpoints compose into different integrations depending on the
    products you hold. These walkthroughs describe the **intended** end-to-end
    shape of each flow. Steps outside your entitlements answer a named error for your
    organization and may support only selected corridors first. Steps against a
    `coming_soon` resource are **not yet callable** - they are shown so you can
    build against the final contract. Check
    **What's available today** and `GET /capabilities` for the current reality.

    ### 1. Full-stack neobank (customer → verify → documents → account → quote → transfer)

    > **Availability:** this is the intended full loop. Screening and quoting
    > are **live**; customer and verification **records** persist. But
    > live provisioning, managed wallet sends, and live account opening are still
    > gated. BRL/Pix accounts, payout destinations, and transfer creation are
    > served; completion to stable/fiat settlement depends on your enabled rails
    > and settlement provider permissions. Build against the contract; watch
    > `GET /capabilities` for go-live.

    Products: `customers`, `compliance`, `liquidity` (plus `custody` for
    wallets).

    1. **Create the end-customer.** `POST /customers` with the identity you
       hold - `type: individual` + `individual.tax_id`, or `type: business` +
       `business.registration`. The customer starts `pending`; the response's
       `verification.mode` tells you which of the two paths below applies.
    2. **Verify - two paths, decided by your organization's posture.**
       Under `reliance` (the default for API integrators): there is nothing to
       start. Complete the profile (`requirements.missing_fields` empty, via
       `POST`/`PATCH /customers`), add associated persons for a business, and
       upload the partner document (`identity_document` for an individual,
       `company_registration` for a business). OZAV screens the customer
       against designated lists on its own and the customer becomes
       `approved` on the call that completes the set - no `POST /verifications`.
       Under `full` (neobank: the end-user is on your screens): start checks with
       `POST /verifications` (`kyc_document`, `liveness`, `face_match`,
       `aml_name_screening`), redirect the end-user to `session_url`, then poll
       `GET /verifications/{id}` or recover sessions with
       `GET /verifications?customer_id=`; in sandbox drive it with
       `POST /verifications/{id}/simulate`. Either way, watch the customer's
       `status` until it is `approved`. Sandbox simulation creates simulated
       managed wallet and BRL/Pix account projections for approved customers
       with complete BR data; it does not create a real on-chain wallet or open
       a real bank account.
    3. **Upload the customer's supporting documents.** `POST
       /customers/{customer_id}/documents` to declare each one, then `PUT` the
       bytes to the `upload_url` it returns. Two calls: a declared document sits
       at `pending_upload` and does not count until the bytes arrive and the
       server matches the hash.

       > **Do not skip this because the customer is `approved`.** `approved` is
       > the verification result; it does not mean the documents are on file,
       > and account opening reads the documents, not the status. On `live`,
       > step 4 refuses until they are all present. In the sandbox it does not,
       > so a run that passes end to end here proves your requests are
       > well-formed, never that the customer is ready. `GET
       > /customers/{customer_id}/documents` shows what you have already sent.

       Which documents apply depends on the customer type and the account you
       are opening; the `422` from step 4 names every one still missing and is
       the authoritative checklist.
    4. **Read or request the customer's named account.** Use
       `GET /accounts?customer_id=...` after sandbox approval, or
       `POST /accounts` to request a named fiat account for a customer whose
       documents are on file. BRL/BR/pix is sandbox-active today; future
       currency and rail combinations use the same endpoint as they are
       enabled. Sandbox returns simulated receiving instructions. Live opening
       requires enablement.
    5. **Register the payout destination.** `POST /payout-destinations` with
       the rail's `details` (e.g. `pix` → `pix_key`; `ach` → account + routing).
       BRL/Pix key destinations can be created; other rails may still
       return a gated provisioning error until enabled.
    6. **Quote.** `POST /quotes` with `source`/`target` currencies; read
       `exchange_conditions` (spread in bps) and `rate`. Quotes are
       short-lived - mind `expires_at`. *(Live today.)*
    7. **Transfer.** `POST /transfers` referencing the `quote_id`, with an
       `Idempotency-Key`. For BRL/Pix on-ramp, use `source.type:
       fiat_payment` and `destination.type: wallet`; the response includes
       Pix payment instructions for that transaction. BRL/Pix off-ramp uses
       `source.type: wallet` and `destination.type: payout_destination` and is
       rollout-gated.
    8. **Track.** Poll `GET /transfers/{id}`: `status` walks
       `awaiting_payment → payment_confirmed → converting → payout_initiated →
       completed`, and `source_leg` / `destination_leg` report each side
       independently.

    ### 2. Liquidity-only (quote → transfer)

    > **Availability:** the quote step is **live today**. Transfer creation is
    > served for BRL/Pix paths; full settlement remains controlled by
    > rail/provider enablement. Other transfer combinations still fail closed
    > until their settlement path is enabled.

    Products: `customers`, `liquidity`. With verified customers and registered
    destinations already in place, the intended flow is two calls:

    1. **Quote** - `POST /quotes` (`source`/`target`). Optionally add
       `spread_bps` as your own additional spread on top of the OZAV all-in
       rate when enabled for your organization. *(Live today.)*
    2. **Transfer** - `POST /transfers` with the `quote_id` and an
       `Idempotency-Key`; then poll `GET /transfers/{id}` (or list with
       `GET /transfers?status=`). *(On the roadmap - see the note above.)*

    ### 3. Compliance-only (screen and verify as a service)

    Products: `customers`, `compliance`. No settlement customer is provisioned
    - this profile is verification-only.

    1. **Screen** - `POST /screenings` against designated lists (`sanctions`,
       `pep`, `adverse_media`). Subjects can be **inline** (a `person` or
       `business`, no customer record required) or reference an existing
       `customer_id`. `result` is `clear`, `match_found`, or `review`
       (conservative - a subject is never silently cleared).
    2. **Verify** - for document, liveness, or UBO checks, `POST /verifications`
       for a customer, then poll `GET /verifications/{id}`. If you lose the id,
       recover it with `GET /verifications?customer_id=`.
    3. **Retain evidence** - keep the `ozav_scr_…` id; reconstruct the trail
       with `GET /screenings?customer_id=`.

    ### 4. Wallets (read balances, build unsigned transactions)

    Products: `custody` (managed) and/or `self_custody`.

    1. **List and read wallets.** `GET /wallets` (filter by `?customer_id=`)
       and `GET /wallets/{id}`. Each wallet reports a `custody_model` -
       `managed` (held under OZAV-side custody) or `self_custody` (the end-user
       holds the key).
    2. **Read balances.** `GET /wallets/{id}/balances` returns balances by
       currency (and network where applicable). Balance reads are
       tenant-scoped: a wallet outside your organization returns `404`.
    3. **Build a transaction (self-custody).** For `self_custody` wallets,
       `POST /wallets/{id}/transaction-intents` returns an **unsigned**
       transaction; your side - or the end-user's wallet - signs and
       broadcasts. OZAV never holds keys for self-custody wallets.
    4. **Read and shape the policy (managed).** `GET /wallets/{id}/policy` shows
       what OZAV applies before signing: allowed destinations, the limits in
       force and their `source`, and the changes still under review.
       `PATCH /wallets/{id}/policy` proposes a new destination or new limits;
       nothing changes until OZAV decides, and `wallet_policy.updated` tells you
       when it does.
    5. **Require your own approvals (managed).** Set `approvals` on the policy
       (`required_approvals`, optional `above_usd`). A covered send then answers
       `202 awaiting_approval` and emits `transfer.approval_required`; approver
       keys holding `approvals:write` decide it at `POST /approvals/{id}/approve`
       or `/reject`. The last approval executes the send and emits
       `transfer.approved`. Mint one key per approver: the key that sent the
       request can never approve it.
    6. **Freeze in an emergency (managed).** `POST /wallets/{id}/freeze` pauses
       every send at once (`409 WALLET_FROZEN` until lifted); deposits keep
       arriving. `POST /wallets/{id}/unfreeze` lifts it - through your quorum
       when one is on.
    7. **Keep an address book (managed).** `GET /wallets/{id}/addresses` lists
       allowed and proposed destinations with your labels;
       `POST /wallets/{id}/addresses` proposes a labelled one (same review as
       the policy); `DELETE /wallets/{id}/addresses/{network}/{address}`
       removes one at once.

    ## Roadmap

    Additional fiat account rails, crypto deposit addresses, and a unified
    transaction feed are in development and will be published to this reference
    when generally available. Webhook endpoint management and delivery history
    are live; the event catalog expands only when a resource actually emits a
    new event type.
  contact:
    name: OZAV Platform
    url: https://www.ozav.io
    email: platform@ozav.io
  termsOfService: https://www.ozav.io/terms
  license:
    name: Proprietary - © OZAV LTDA
    url: https://www.ozav.io/terms
x-ozav-personas:
  - key: neobank
    description: Full-stack digital banking - customers, quotes, transfers, wallets,
      compliance.
  - key: liquidity
    description: Liquidity-only - quotes, on/off-ramp transfers, payout destinations.
  - key: compliance
    description: Compliance-only - screenings and verifications as a standalone service.
  - key: custody
    description: Wallets under managed custody or self-custody (client-signed) models.
servers:
  - url: https://developers.ozav.io/v2
    description: Production (live and test keys; environment is derived from the key prefix)
security:
  - ApiKeyAuth: []
  - BearerAuth: []
tags:
  - name: Capabilities
    description: |
      Discover, at runtime, exactly what a key can do. `GET /capabilities`
      returns the caller's **entitlements** (the products enabled for the
      organization), the **scopes** granted to the presented key, and a
      per-feature readiness map (`live`, `beta`, `coming_soon`). Call it first
      in any integration so your code branches on real access instead of
      guessing.
  - name: Customers
    description: |
      The anchor resource every other object hangs off. A customer is an
      end-user you serve - an `individual` or a `business` - created once with
      the identity you hold. Verification, payout destinations, wallets,
      quotes, and transfers all reference a `customer_id`. Business customers
      carry associated persons (UBOs, directors, officers, and signers).

      **Creating the customer asks for no documents.** Every field on `POST
      /customers` is optional beyond the type and the contact, and what is still
      owed comes back on the record itself as `requirements.missing_fields`.
      Documents are a separate mechanism: they are registered with `POST
      /customers/{customer_id}/documents` and then uploaded, and they are
      deliberately NOT reported in `missing_fields` - see the note on that field.

      **Who owes documents at all:** an `individual` owes none, ever. A
      `business` owes a dossier only when a BRL account is opened for it. The
      list, the literal `doc_type` values and the two-call upload sequence are in
      **"What each customer type must provide"** under *Onboarding & Compliance*.
  - name: Verifications
    description: |
      Identity and business verification sessions (KYC / KYB). Start one or
      more checks for a customer from the canonical capability taxonomy -
      document, liveness, face match, proof of address, UBO, and more - then
      poll the session until each check reports an outcome. Use this when you
      need verified identity, not just a list screening.
  - name: Screenings
    description: |
      Standalone designated-list screening as a service - one call, no
      customer record required. Screen a person, business, existing customer,
      or a wallet address against OZAV's in-house dataset (UN, OFAC,
      EU, UK, and national sources, refreshed daily). Results are conservative:
      an inconclusive subject is flagged `review`, never silently cleared.
  - name: Quotes
    description: |
      Amounts below the platform minimum for the fixed leg's currency are refused with
      `400 AMOUNT_BELOW_MINIMUM`, and the message names the minimum. Quoting below it
      would promise an execution that can never settle.

      FX quotes with OZAV all-in rates. A quote is opaque, short-lived, and
      may carry an additional client-selected spread in basis points, then is
      executed by creating a transfer that references it. Internal cost, floor,
      and margin analytics are not part of the public response.
  - name: Transfers
    description: |
      The single money-movement resource. One endpoint is intended to serve
      every flow - FX, on-ramp, off-ramp, wallet-to-wallet, and crypto
      send/receive - selected by the `(source.type, destination.type)` pair
      rather than by a different URL. Selected BRL/Pix
      paths create real provider-neutral transfer records and Pix payment
      instructions; other combinations fail closed with a stable error rather
      than returning a silent partial result.
  - name: Payout Destinations
    description: |
      Where a customer's funds land off-ramp - bank, PIX, and other rails -
      as one canonical resource with a rail-keyed `details` union.
      BRL/Pix key destinations persist as
      provider-neutral OZAV records. Rails that require external venue
      provisioning may still fail closed until enabled.
  - name: Accounts
    description: |
      Customer-owned named fiat accounts for receiving funds into OZAV.
      The same `ozav_acc_` resource covers BRL/Pix,
      USD/ACH, USD/wire, EUR/SEPA, GBP/Faster Payments, and future fiat rails
      as they are enabled. Sandbox accounts are simulated and cost-free; live
      opening requires enablement for your organization. The public response
      never exposes the banking venue behind the account.

      **Which documents a live account needs depends on the currency, and this is
      the single most common round trip.** Opening a **BRL** account for a
      business asks for a six-document dossier plus an identity document per
      associated person. Opening **USD, EUR or GBP** transmits structured data
      only and asks for no files at all. An **individual** never uploads files
      for any currency - a hosted verification session collects the identity
      artifacts directly from the person.

      The full list, the literal `doc_type` values, and the two-call upload
      sequence are in **"What each customer type must provide"** under
      *Onboarding & Compliance*. You do not have to guess it up front: `POST
      /accounts` refuses an incomplete dossier with `422
      ACCOUNT_PROFILE_INCOMPLETE` and a `missing` list naming what is absent.

      ⚠️ A business that started with a foreign-currency account uploaded
      nothing, so choosing BRL later is the first time the dossier is asked for.
      If BRL is anywhere on your roadmap, collect it before you need it.
  - name: Wallets
    description: |
      Balances and custody for a customer's holdings. Each wallet reports a
      `custody_model` - `managed` (held under OZAV-side custody arrangements)
      or `self_custody` (the end-user holds the key). Reads are tenant-scoped:
      a wallet outside your organization returns `404`, never a leak.
  - name: Cards
    description: |
      Canonical OZAV card issuing and lifecycle surface. Cards use opaque
      `ozav_card_` ids, are funded from OZAV balances (`prepaid_balance`), and
      never expose issuing-provider identifiers or sensitive card data. Virtual
      cards issue end-to-end in sandbox; live issuance is enabled per
      organization once the card program is active.
  - name: Approvals
    description: |
      Your own approval quorum for managed-custody sends. When a wallet's policy
      says `approvals.mode: customer_quorum`, a covered send answers
      `202 awaiting_approval` and waits here until `required_approvals` of your
      approver keys (scope `approvals:write`, other than the key that sent it)
      approve. The last approval executes the send in that same request; any
      rejection releases it; 72 hours unanswered, it expires. Your quorum is one
      more gate before OZAV signs, never one less: OZAV's screening, limits and
      review still apply after it.
  - name: Transaction Intents
    description: |
      Unsigned-transaction building for `self_custody` wallets. OZAV assembles
      the calldata and returns an unsigned transaction; signing and
      broadcasting stay entirely on your side or the end-user's wallet. OZAV
      never holds keys for self-custody wallets.
  - name: Yield
    description: |
      Read the yield strategies OZAV admits and the positions a customer holds
      in them, with acquisition cost and realized performance measured on-chain.
      Each strategy carries a stable, opaque `id`; the underlying venue and its
      contract address are never exposed. Applying and redeeming under managed
      custody are not available yet. A client that holds its own key can have
      OZAV assemble unsigned deposit and withdrawal transactions, which OZAV
      never signs and never broadcasts; that path is in a closed pilot.
  - name: Swap
    description: |
      Quote a stablecoin conversion across the networks OZAV supports. The quote
      compares independent sources, reports the network cost on its own line,
      and states what the conversion costs without naming the venue that
      produced it. Execution is not exposed yet.
  - name: API Keys
    description: |
      Self-serve key lifecycle: create keys scoped to a subset of your
      entitlements, list them (prefixes only - secrets are shown once at
      creation), rotate with an overlap window, and revoke. Test and live keys
      differ only by prefix; the environment is derived from it.
  - name: Webhooks
    description: |
      Register HTTPS endpoints to receive signed event notifications. Each
      endpoint has its own signing secret (`ozav_whsec_`, shown once at create /
      rotate) and a list of subscribed event types. Endpoints are tenant-scoped.

      **Deliveries are also scoped by environment.** An event produced by a live
      key is delivered only to endpoints registered in `live`, and a sandbox
      event only to `test` endpoints. If you integrate in both, register one
      endpoint per environment - a single endpoint no longer receives both.

      **Verifying a delivery.** Every delivery carries three headers:

      ```
      X-OZAV-Signature:   t=<unix>,v1=<hex>
      X-OZAV-Event-ID:    stable across retries, dedupe on it
      X-OZAV-Delivery-ID: unique per delivery attempt
      ```

      Parse `t` and `v1` out of `X-OZAV-Signature`, then recompute:

      ```
      v1 == HMAC_SHA256(key = <your endpoint secret, WITH the ozav_whsec_ prefix>,
                        message = "<t>." + <raw request body>)
      ```

      Three things break verification and are worth stating plainly, because each
      one fails silently on your side:

      1. **The key is the whole secret string, prefix included.** `ozav_whsec_` is
         part of the key, not a display label. Stripping it fails 100% of deliveries.
      2. **Sign the raw body bytes**, before any JSON parse. Re-serializing may or
         may not reproduce the same bytes, so do not rely on it.
      3. **Compare against `v1` only**, not the whole `t=...,v1=...` header value.

      Reject deliveries whose `t` is outside your tolerance window (five minutes is
      recommended) and dedupe by `X-OZAV-Event-ID`.

      **When deliveries are not arriving**, read
      `GET /webhook-endpoints/{id}/deliveries` before debugging signatures. It
      reports `response_status` (what your server answered) and `error` (transport
      failures). A `response_status` of `401` is a signature problem; a null
      `response_status` with an `error` is a network problem such as DNS or TLS,
      and no signature change will fix it.

x-tagGroups:
  - name: Getting Started
    tags: [ Capabilities ]
  - name: Onboarding & Compliance
    tags: [ Customers, Verifications, Screenings ]
  - name: Money Movement
    tags: [ Quotes, Transfers, Accounts, Payout Destinations ]
  - name: Wallets & Custody
    tags: [ Wallets, Approvals, Cards, Transaction Intents, Yield, Swap ]
  - name: Platform
    tags: [ API Keys, Webhooks ]

paths:
  /capabilities:
    get:
      operationId: getCapabilities
      tags: [ Capabilities ]
      summary: Get the caller's entitlements, key scopes, and feature readiness
      description: |
        Returns the two authorization axes for the presented key - the
        organization's `entitlements` and the key's `scopes` - plus the resolved
        `environment` and a per-feature readiness map (`live`, `beta`,
        `coming_soon`). Call it first in any integration and branch on the access
        it reports rather than hard-coding assumptions. Requires only a valid key
        (any entitlement, no specific scope).
      x-ozav-entitlement: any
      x-ozav-scopes: []
      x-ozav-availability: live
      responses:
        "200":
          description: The two authorization axes and per-feature availability.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entitlements:
                    type: array
                    items: { type: string }
                    description: Products enabled for the organization (e.g. liquidity, compliance,
                      customers, cards, custody, self_custody, kyc_reliance).
                  scopes:
                    type: array
                    items: { type: string }
                    description: Scopes granted to the presented API key.
                  environment:
                    type: string
                    description: Which environment the presented key resolves to - `test` (sandbox)
                      or `live` (production).
                    enum: [ test, live ]
                  features:
                    type: object
                    description: |
                      Per-feature readiness, keyed by feature name. Each value is one of
                      `live` (generally available), `beta` (available to design partners,
                      may need enablement), or `coming_soon` (defined but not yet callable).
                    additionalProperties: { type: string, enum: [ live, beta, coming_soon ] }
                  jurisdictions:
                    type: object
                    description: |
                      Where OZAV onboards customers, decided by declared residence
                      and place of registration - never by nationality alone.

                      `refused` is enforced at creation: a customer whose tax
                      identity or address is issued there is rejected with
                      `422 JURISDICTION_NOT_SERVED`. `enhanced_due_diligence` is
                      accepted, with longer review and further evidence requested.

                      Anywhere else is served, subject to name screening and a
                      risk-based decision. Absence from both lists means you may
                      proceed; it is not by itself a guarantee of acceptance.
                    properties:
                      refused:
                        type: array
                        items: { type: string, pattern: "^[A-Z]{2}$" }
                      enhanced_due_diligence:
                        type: array
                        items: { type: string, pattern: "^[A-Z]{2}$" }
              examples:
                neobank_test_key:
                  summary: A full-stack test key
                  value:
                    entitlements:
                      - customers
                      - compliance
                      - liquidity
                      - custody
                    scopes:
                      - customers:read
                      - customers:write
                      - quotes:write
                      - accounts:read
                      - accounts:write
                      - transfers:write
                      - transfers:read
                      - destinations:write
                      - screenings:write
                    environment: test
                    features:
                      quotes: live
                      screenings: live
                      transfers: live
                      accounts: live
                      verifications: live
                      wallets: live
        "401": { $ref: "#/components/responses/Unauthorized" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/capabilities \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/capabilities", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/capabilities",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /customers:
    post:
      operationId: createCustomer
      tags: [ Customers ]
      summary: Create a customer
      description: |
        Creates a customer - the anchor resource that quotes, verifications,
        accounts, payout destinations, wallets, and transfers all reference. Supply
        `type: individual` with an `individual` block (including
        `individual.tax_id`) or `type: business` with a `business` block
        (including `business.registration`); the required identity is enforced
        server-side. The customer always starts `status: pending` and is verified
        separately (see `POST /verifications`). Accepts an optional
        `Idempotency-Key` (recommended). Returns the created `Customer`.

        The record persists and reads back. Customer creation does
        not open a real wallet or bank account by itself. In sandbox,
        verification simulation can materialize simulated wallet and BRL/Pix
        account projections after approval; live provisioning requires
        enablement for your organization.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerCreateRequest" }
            examples:
              individual:
                summary: Individual customer
                value:
                  type: individual
                  email: ana@example.com
                  external_ref: your-user-4821
                  individual:
                    legal_name: Ana Ramos
                    date_of_birth: 1990-04-12
                    nationality: BR
                    tax_id:
                      country: BR
                      type: cpf
                      value: "39053344705"
                    residential_address:
                      line1: Rua das Flores 100
                      city: São Paulo
                      state: SP
                      postal_code: 01000-000
                      country: BR
              business:
                summary: Business customer
                value:
                  type: business
                  email: ops@acme.com.br
                  business:
                    legal_name: Acme Pagamentos Ltda
                    registration:
                      country: BR
                      type: cnpj
                      value: "11222333000181"
      responses:
        "201":
          description: Customer created.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
              examples:
                individual_pending:
                  summary: Created - always starts pending
                  value:
                    id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: customer
                    type: individual
                    status: pending
                    external_ref: your-user-4821
                    email: ana@example.com
                    individual:
                      legal_name: Ana Ramos
                      date_of_birth: 1990-04-12
                      nationality: BR
                      tax_id:
                        country: BR
                        type: cpf
                        value: "39053344705"
                    verification:
                      mode: full
                      status: pending
                      checks: {}
                    requirements:
                      due: []
                      missing_fields:
                        [
                          mother_name,
                          phone,
                          residential_address,
                          source_of_funds,
                          expected_monthly_volume_usd,
                          monthly_income_usd,
                          occupation,
                          is_pep,
                          account_purpose
                        ]
                    created_at: 2026-07-12T18:00:00Z
                    updated_at: 2026-07-12T18:00:00Z
        "202": { $ref: "#/components/responses/IdempotencyProcessing" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500":
          description: >
            The customer was persisted but its public representation could not
            be produced (`CUSTOMER_MAPPING_FAILED`). This is now narrow: it
            fires only when a field the schema marks REQUIRED cannot be
            produced. A record that is merely incomplete is returned with its
            optional blocks omitted instead (see `CustomerVerification.mode`),
            so one incomplete record never fails a call. The record may exist -
            reconcile with `GET /customers` before creating another.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "type": "individual",
                "email": "ana@example.com",
                "external_ref": "your-user-4821",
                "individual": {
                  "legal_name": "Ana Ramos",
                  "date_of_birth": "1990-04-12",
                  "nationality": "BR",
                  "tax_id": {
                    "country": "BR",
                    "type": "cpf",
                    "value": "39053344705"
                  },
                  "residential_address": {
                    "line1": "Rua das Flores 100",
                    "city": "São Paulo",
                    "state": "SP",
                    "postal_code": "01000-000",
                    "country": "BR"
                  }
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/customers", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "type": "individual",
                "email": "ana@example.com",
                "external_ref": "your-user-4821",
                "individual": {
                  "legal_name": "Ana Ramos",
                  "date_of_birth": "1990-04-12",
                  "nationality": "BR",
                  "tax_id": {
                    "country": "BR",
                    "type": "cpf",
                    "value": "39053344705"
                  },
                  "residential_address": {
                    "line1": "Rua das Flores 100",
                    "city": "São Paulo",
                    "state": "SP",
                    "postal_code": "01000-000",
                    "country": "BR"
                  }
                }
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/customers",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "type": "individual",
                    "email": "ana@example.com",
                    "external_ref": "your-user-4821",
                    "individual": {
                        "legal_name": "Ana Ramos",
                        "date_of_birth": "1990-04-12",
                        "nationality": "BR",
                        "tax_id": {
                            "country": "BR",
                            "type": "cpf",
                            "value": "39053344705",
                        },
                        "residential_address": {
                            "line1": "Rua das Flores 100",
                            "city": "São Paulo",
                            "state": "SP",
                            "postal_code": "01000-000",
                            "country": "BR",
                        },
                    },
                },
            )
            data = response.json()
    get:
      operationId: listCustomers
      tags: [ Customers ]
      summary: List customers
      description: |
        Lists your customers, newest first, using the standard cursor pagination
        (`limit`, `starting_after`). Returns a `Page` whose `data` is an array of
        `Customer`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: Paginated customers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Customer" }
              examples:
                page:
                  summary: One page of customers
                  value:
                    data:
                      - id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: customer
                        type: individual
                        status: approved
                        email: ana@example.com
                        individual:
                          legal_name: Ana Ramos
                          tax_id: { country: BR, type: cpf, value: "39053344705" }
                        verification: { mode: full, status: approved, checks: {} }
                        requirements:
                          {
                            due: [],
                            missing_fields:
                              [
                                mother_name,
                                phone,
                                residential_address,
                                source_of_funds,
                                expected_monthly_volume_usd,
                                monthly_income_usd,
                                occupation,
                                is_pep,
                                account_purpose
                              ]
                          }
                        created_at: 2026-07-12T18:00:00Z
                        updated_at: 2026-07-12T18:05:00Z
                    has_more: false
                    next_cursor: null
                    still_required: [ company_registration, ownership_structure, source_of_funds ]
                incompleto:
                  summary: A record OZAV cannot fully represent, alongside a complete one
                  description: >
                    The second record has no recorded verification posture, so
                    `verification.mode`, `individual` and `business` are omitted
                    and `requirements.missing_fields` names what is still
                    needed. Note the first record is unaffected: one incomplete
                    record never fails the call, and its `status` must not be
                    read as a verification attestation while `mode` is absent.
                  value:
                    data:
                      - id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: customer
                        type: individual
                        status: approved
                        email: ana@example.com
                        individual:
                          legal_name: Ana Ramos
                          tax_id: { country: BR, type: cpf, value: "39053344705" }
                        verification: { mode: full, status: approved, checks: {} }
                        requirements:
                          {
                            due: [],
                            missing_fields:
                              [
                                mother_name,
                                phone,
                                residential_address,
                                source_of_funds,
                                expected_monthly_volume_usd,
                                monthly_income_usd,
                                occupation,
                                is_pep,
                                account_purpose
                              ]
                          }
                        created_at: 2026-07-12T18:00:00Z
                        updated_at: 2026-07-12T18:05:00Z
                      - id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V2
                        object: customer
                        type: business
                        status: approved
                        email: contato@example.com
                        verification: { status: approved, checks: {} }
                        requirements:
                          due: []
                          missing_fields: [ legal_name, registration, registered_address ]
                        created_at: 2026-07-12T18:10:00Z
                        updated_at: 2026-07-12T18:10:00Z
                    has_more: false
                    next_cursor: null
        "401": { $ref: "#/components/responses/Unauthorized" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/customers", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/customers",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /customers/terms-sessions:
    post:
      operationId: createTermsSession
      tags: [ Customers ]
      summary: Create a terms session
      description: |
        Creates a terms-of-service session. Customer provisioning at a settlement
        partner is fail-closed on an accepted terms session: `POST /customers`
        returns `422 TERMS_ACCEPTANCE_REQUIRED` until you submit the
        `terms_session_id` of a session that has been accepted.

        The response carries `id` - the session token - **once, at creation**.
        OZAV stores only its hash, so it is never returned again. Persist it
        before you present the terms to your end user.

        Present `terms_url` to the end user, collect their acceptance in your own
        flow, then record it with `POST /customers/terms-sessions/accept`.
        Sessions expire; `expires_at` is absolute.

        Accepts an optional `Idempotency-Key`. Replaying the same key returns the
        same session **without** `id` - the token is only ever issued once.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      responses:
        "201":
          description: Terms session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TermsSession" }
              examples:
                created:
                  summary: Created - token returned once
                  value:
                    id: ts_8Kd2mQ4vR7xN1pL5wT3yB6zA9cF0hJ2s
                    object: terms_session
                    status: pending
                    terms_url: https://ozav.io/terms
                    terms_version: "2.0"
                    expires_at: 2026-09-03T18:00:00Z
        "200":
          description: Idempotent replay - the same session, without the token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TermsSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/terms-sessions \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/customers/terms-sessions", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/customers/terms-sessions",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                },
            )
            data = response.json()
  /customers/terms-sessions/accept:
    post:
      operationId: acceptTermsSession
      tags: [ Customers ]
      summary: Record acceptance of a terms session
      description: |
        Records that your end user accepted the terms of the session, together
        with the evidence of that acceptance. You collect the acceptance in your
        own flow; this call is your attestation of it, and OZAV stores it against
        the terms version that was in force when the session was created.

        Idempotent: repeating the call returns the same result and never
        overwrites the original evidence. The session token is **not** returned.

        After a `200`, submit the same `terms_session_id` to `POST /customers`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TermsSessionAcceptRequest" }
            examples:
              accept:
                summary: Attest the acceptance you collected
                value:
                  terms_session_id: ts_8Kd2mQ4vR7xN1pL5wT3yB6zA9cF0hJ2s
                  accepted_by:
                    legal_name: Ana Ramos
                    email: ana@example.com
                  accepted_at: 2026-09-02T17:41:09Z
                  ip_address: 203.0.113.42
                  user_agent: Mozilla/5.0
      responses:
        "200":
          description: Acceptance recorded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TermsSession" }
              examples:
                accepted:
                  summary: Accepted
                  value:
                    object: terms_session
                    status: accepted
                    terms_url: https://ozav.io/terms
                    terms_version: "2.0"
                    expires_at: 2026-09-03T18:00:00Z
                    accepted_at: 2026-09-02T17:41:09Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/terms-sessions/accept \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "terms_session_id": "ts_8Kd2mQ4vR7xN1pL5wT3yB6zA9cF0hJ2s",
                "accepted_by": {
                  "legal_name": "Ana Ramos",
                  "email": "ana@example.com"
                },
                "accepted_at": "2026-09-02T17:41:09Z",
                "ip_address": "203.0.113.42",
                "user_agent": "Mozilla/5.0"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/terms-sessions/accept", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "terms_session_id": "ts_8Kd2mQ4vR7xN1pL5wT3yB6zA9cF0hJ2s",
                "accepted_by": {
                  "legal_name": "Ana Ramos",
                  "email": "ana@example.com"
                },
                "accepted_at": "2026-09-02T17:41:09Z",
                "ip_address": "203.0.113.42",
                "user_agent": "Mozilla/5.0"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/customers/terms-sessions/accept",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "terms_session_id": "ts_8Kd2mQ4vR7xN1pL5wT3yB6zA9cF0hJ2s",
                    "accepted_by": {
                        "legal_name": "Ana Ramos",
                        "email": "ana@example.com",
                    },
                    "accepted_at": "2026-09-02T17:41:09Z",
                    "ip_address": "203.0.113.42",
                    "user_agent": "Mozilla/5.0",
                },
            )
            data = response.json()
  /customers/{customer_id}:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
    get:
      operationId: getCustomer
      tags: [ Customers ]
      summary: Retrieve a customer
      description: |
        Retrieves a single customer by its `ozav_cus_` id, including its current
        `status`, `verification` posture, and outstanding `requirements`. An id
        that does not exist or belongs to another organization returns `404`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The customer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
              examples:
                approved:
                  summary: A verified individual customer
                  value:
                    id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: customer
                    type: individual
                    status: approved
                    external_ref: your-user-4821
                    email: ana@example.com
                    individual:
                      legal_name: Ana Ramos
                      date_of_birth: 1990-04-12
                      nationality: BR
                      tax_id: { country: BR, type: cpf, value: "39053344705" }
                    verification:
                      mode: full
                      status: approved
                      checks:
                        kyc_document: { outcome: pass, verified_at: 2026-07-12T18:04:00Z }
                    requirements:
                      {
                        due: [],
                        missing_fields:
                          [
                            mother_name,
                            phone,
                            residential_address,
                            source_of_funds,
                            expected_monthly_volume_usd,
                            monthly_income_usd,
                            occupation,
                            is_pep,
                            account_purpose
                          ]
                      }
                    created_at: 2026-07-12T18:00:00Z
                    updated_at: 2026-07-12T18:05:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    patch:
      operationId: updateCustomer
      tags: [ Customers ]
      summary: Update a customer
      description: |
        Updates a customer's mutable attributes (contact fields, identity blocks,
        metadata). Only the fields you include are changed; omitted fields are
        left untouched. Returns the updated `Customer`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerUpdateRequest" }
            examples:
              contact_update:
                summary: Update contactable fields
                value:
                  email: ana.ramos@example.com
                  metadata:
                    tier: gold
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Updated customer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Customer" }
              examples:
                updated:
                  summary: Contact email and metadata updated
                  value:
                    id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: customer
                    type: individual
                    status: approved
                    email: ana.ramos@example.com
                    individual:
                      legal_name: Ana Ramos
                      tax_id: { country: BR, type: cpf, value: "39053344705" }
                    verification: { mode: full, status: approved, checks: {} }
                    requirements:
                      {
                        due: [],
                        missing_fields:
                          [
                            mother_name,
                            phone,
                            residential_address,
                            source_of_funds,
                            expected_monthly_volume_usd,
                            monthly_income_usd,
                            occupation,
                            is_pep,
                            account_purpose
                          ]
                      }
                    created_at: 2026-07-12T18:00:00Z
                    updated_at: 2026-07-12T18:10:00Z
                    metadata: { tier: gold }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "email": "ana.ramos@example.com",
                "metadata": {
                  "tier": "gold"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "email": "ana.ramos@example.com",
                "metadata": {
                  "tier": "gold"
                }
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.patch(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "email": "ana.ramos@example.com",
                    "metadata": {
                        "tier": "gold",
                    },
                },
            )

            data = response.json()

  /customers/{customer_id}/associated-persons:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
    post:
      operationId: createAssociatedPerson
      tags: [ Customers ]
      summary: Add an associated person (UBO, director, officer, or signer) to a
        business customer
      description: |
        Adds an associated person - a UBO, director, officer, or authorized
        signer - to a business customer, for KYB. Supply one or more `roles` and
        the person's `individual` identity block (and `ownership_percent` for
        owners). Valid only for `business` customers. Returns the created
        `AssociatedPerson`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssociatedPersonCreateRequest" }
            examples:
              ubo:
                summary: A 40%-owning UBO who is also a director
                value:
                  roles:
                    - ubo
                    - director
                  ownership_percent: 40
                  individual:
                    legal_name: Carlos Souza
                    tax_id:
                      country: BR
                      type: cpf
                      value: "39053344705"
                  email: carlos@acme.com.br
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Associated person created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssociatedPerson" }
              examples:
                ubo:
                  summary: A 40%-owning UBO who is also a director
                  value:
                    id: ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: associated_person
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    roles: [ ubo, director ]
                    ownership_percent: 40
                    individual:
                      legal_name: Carlos Souza
                      tax_id: { country: BR, type: cpf, value: "39053344705" }
                    email: carlos@acme.com.br
                    verification: { mode: full, status: pending, checks: {} }
                    created_at: 2026-07-12T18:00:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "roles": [
                  "ubo",
                  "director"
                ],
                "ownership_percent": 40,
                "individual": {
                  "legal_name": "Carlos Souza",
                  "tax_id": {
                    "country": "BR",
                    "type": "cpf",
                    "value": "39053344705"
                  }
                },
                "email": "carlos@acme.com.br"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "roles": [
                  "ubo",
                  "director"
                ],
                "ownership_percent": 40,
                "individual": {
                  "legal_name": "Carlos Souza",
                  "tax_id": {
                    "country": "BR",
                    "type": "cpf",
                    "value": "39053344705"
                  }
                },
                "email": "carlos@acme.com.br"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "roles": [
                        "ubo",
                        "director",
                    ],
                    "ownership_percent": 40,
                    "individual": {
                        "legal_name": "Carlos Souza",
                        "tax_id": {
                            "country": "BR",
                            "type": "cpf",
                            "value": "39053344705",
                        },
                    },
                    "email": "carlos@acme.com.br",
                },
            )

            data = response.json()
    get:
      operationId: listAssociatedPersons
      tags: [ Customers ]
      summary: List a customer's associated persons
      description: |
        Lists the associated persons of a business customer, using the standard
        cursor pagination. Returns a `Page` of `AssociatedPerson`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Associated persons.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AssociatedPerson" }
              examples:
                page:
                  summary: One page of associated persons
                  value:
                    data:
                      - id: ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: associated_person
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        roles: [ ubo, director ]
                        ownership_percent: 40
                        individual:
                          legal_name: Carlos Souza
                          tax_id: { country: BR, type: cpf, value: "39053344705" }
                        verification: { mode: full, status: pending, checks: {} }
                        created_at: 2026-07-12T18:00:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /customers/{customer_id}/associated-persons/{person_id}:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
      - name: person_id
        in: path
        required: true
        description: The opaque associated-person id (`ozav_ap_...`).
        schema: { type: string }
    patch:
      operationId: updateAssociatedPerson
      tags: [ Customers ]
      summary: Update an associated person
      description: |
        Updates an associated person's mutable attributes (roles, ownership
        percentage, identity block). Only included fields are changed. Returns
        the updated `AssociatedPerson`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssociatedPersonUpdateRequest" }
            examples:
              ownership:
                summary: Revise ownership
                value:
                  ownership_percent: 55
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Updated associated person.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssociatedPerson" }
              examples:
                updated:
                  summary: Ownership revised to 55%
                  value:
                    id: ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: associated_person
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    roles: [ ubo, director ]
                    ownership_percent: 55
                    individual:
                      legal_name: Carlos Souza
                      tax_id: { country: BR, type: cpf, value: "39053344705" }
                    verification: { mode: full, status: pending, checks: {} }
                    created_at: 2026-07-12T18:00:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons/ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "ownership_percent": 55
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons/ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "ownership_percent": 55
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.patch(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/associated-persons/ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "ownership_percent": 55,
                },
            )

            data = response.json()

  /customers/{customer_id}/documents:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
    post:
      operationId: createCustomerDocument
      tags: [ Customers ]
      summary: Start a supporting-document upload for a customer
      description: |
        Declares a provider-neutral KYC/KYB document and returns a short-lived
        `upload_url` the client PUTs the file bytes to. Documents may belong to
        the customer itself or to one of its associated persons. Use generic
        document categories such as `company_registration`, `identity_document`,
        `selfie`, `liveness`, `proof_of_address`, `ownership_structure`,
        `financial_statement`, and `source_of_funds`; OZAV maps those into any
        provider-specific payload behind the public API.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerDocumentUploadRequest" }
            examples:
              proof_of_address:
                summary: Declare a proof-of-address PDF
                value:
                  doc_type: proof_of_address
                  mime: application/pdf
              associated_person_identity_front:
                summary: Declare a representative identity document
                value:
                  subject_type: associated_person
                  associated_person_id: ozav_ap_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  doc_type: identity_document
                  side: front
                  issuing_country: BR
                  mime: image/jpeg
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "201":
          description: Upload grant created; PUT the file bytes to `upload_url`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerDocumentUpload" }
              examples:
                created:
                  summary: A pending-upload document with its signed URL
                  value:
                    id: ozav_doc_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: document
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    subject_type: customer
                    doc_type: proof_of_address
                    side: null
                    issuing_country: null
                    status: pending_upload
                    mime: application/pdf
                    size_bytes: null
                    sha256: null
                    created_at: 2026-07-12T18:00:00Z
                    updated_at: 2026-07-12T18:00:00Z
                    upload_url: https://storage.ozav.io/upload/ozav_cus_01J8.../ozav_doc_01J8...
                    upload_expires_at: 2026-07-12T20:00:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "doc_type": "proof_of_address",
                "mime": "application/pdf"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "doc_type": "proof_of_address",
                "mime": "application/pdf"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "doc_type": "proof_of_address",
                    "mime": "application/pdf",
                },
            )

            data = response.json()
    get:
      operationId: listCustomerDocuments
      tags: [ Customers ]
      summary: List a customer's supporting documents
      description: |
        Lists a customer's uploaded supporting documents, using the standard cursor
        pagination. Returns a `Page` of `CustomerDocument`. Never returns the raw
        storage location or a download URL.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Customer documents.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/CustomerDocument" }
                      still_required:
                        type: array
                        items: { $ref: "#/components/schemas/CustomerDocumentType" }
                        description: >-
                          The document categories this customer still owes,
                          computed over ALL of its documents rather than the
                          page you are reading, so paging does not change the
                          answer. A document counts only once its bytes have
                          arrived: one you declared but never uploaded, and one
                          that failed review, both still appear here.


                          **Read its ABSENCE carefully. An empty array and a
                          missing field mean opposite things.** An empty array
                          says the customer owes nothing. The field is OMITTED
                          when OZAV does not model a required set for this
                          customer, which today means only a `type` that is
                          neither `individual` nor `business`. Individuals and
                          businesses of any country are modelled. Omitted never
                          means "nothing is required": it means we will not
                          guess. It is also omitted if the summary could not be
                          computed, so that a failed read can never be read as a
                          clean bill.


                          The set depends on the customer and the jurisdiction,
                          never on a provider. An individual owes a photo ID and
                          a proof of address (plus a tax return above the
                          high-volume threshold). A business owes its
                          incorporation papers, and its ownership registry where
                          the country and legal form keep them as separate
                          documents. A business registered in Brazil
                          additionally owes the financial pack the local account
                          rail requires.

                          This is guidance, not the gate. The `422` from account
                          opening remains the authoritative checklist, and this
                          list is built to never ask for less than that `422`
                          will.
              examples:
                page:
                  summary: One page of documents
                  value:
                    data:
                      - id: ozav_doc_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: document
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        doc_type: proof_of_address
                        status: received
                        mime: application/pdf
                        size_bytes: 20480
                        sha256: b9f3a1c2d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f
                        created_at: 2026-07-12T18:00:00Z
                        updated_at: 2026-07-12T18:05:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /customers/{customer_id}/documents/{document_id}/upload:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
      - $ref: "#/components/parameters/DocumentId"
    put:
      operationId: uploadCustomerDocumentBytes
      tags: [ Customers ]
      summary: Upload supporting-document bytes
      description: |
        Uploads file bytes to the short-lived OZAV-hosted `upload_url`
        returned by `POST /customers/{customer_id}/documents`. Send the same
        API key used to create the grant. OZAV stores the file in private
        storage, recomputes size and SHA-256 server-side, and moves the
        document to `received` when the bytes are accepted.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:write ]
      x-ozav-availability: live
      parameters:
        - name: upload_token
          in: query
          required: true
          description: Short-lived upload token embedded in the `upload_url`.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/pdf:
            schema: { type: string, format: binary }
          image/png:
            schema: { type: string, format: binary }
          image/jpeg:
            schema: { type: string, format: binary }
          image/webp:
            schema: { type: string, format: binary }
          image/heic:
            schema: { type: string, format: binary }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "200":
          description: Document bytes accepted and the document is marked received.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerDocument" }
              examples:
                received:
                  summary: A received supporting document
                  value:
                    id: ozav_doc_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: document
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    doc_type: proof_of_address
                    status: received
                    mime: application/pdf
                    size_bytes: 2048
                    sha256: 5f70bf18a08600701687fb0d4f009dc7bbdc9e2b0e7f2d7d1e04a1fbf30abbb5
                    created_at: 2026-07-12T18:00:00Z
                    updated_at: 2026-07-12T18:02:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents/{document_id}/upload \
              -X PUT \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents/{document_id}/upload", {
              method: "PUT",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.put(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents/{document_id}/upload",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /customers/{customer_id}/evidence:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
    get:
      operationId: getCustomerEvidencePack
      tags: [ Compliance ]
      summary: Retrieve the verification and decision record for a customer
      description: |
        Returns the **verification and decision record** for one customer: what
        OZAV verified, which lists were screened, which documents were assessed,
        which decisions were taken on their movements, and what happened
        afterwards. Hand it to your bank or your regulator as evidence that the
        decision was made and can be defended.

        Ask for `format=html` to get a printable document instead of JSON; both
        come from the same record, so they can never disagree.

        **An empty section means there is no record, never approval.** A section
        that could not be read is listed in `secoesIndisponiveis` and the verdict
        falls back to `em_analise` - the record never asserts what it did not
        read.

        The record is assembled at request time and carries `geradoEm`. There is
        no frozen document id on purpose: a compliance document that freezes
        ages silently, and an "approved" from three months ago shown to a bank
        while a new alert is open is worse than no document at all.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ evidence:read ]
      x-ozav-availability: live
      parameters:
        - name: format
          in: query
          required: false
          description: "`json` (default) or `html` for a printable document."
          schema: { type: string, enum: [ json, html ] }
        - name: lang
          in: query
          required: false
          description: "Language of the printable document: `pt` (default) or `en`."
          schema: { type: string, enum: [ pt, en ] }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The verification and decision record.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EvidencePack" }
            text/html:
              schema: { type: string }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/evidence \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/evidence", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/evidence",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /customers/{customer_id}/credential:
    parameters:
      - $ref: "#/components/parameters/CustomerId"
    get:
      operationId: getCustomerCredential
      tags: [ Customers ]
      summary: Issue a portable verification credential for an approved customer
      description: |
        Returns a **portable credential** signed by OZAV that attests what OZAV
        verified about this customer, when, and how - as an IETF SD-JWT
        (`vc+sd-jwt`, ES256). Hand it to the end user: they can present it to
        any third party and choose which attributes to disclose (for example
        `over_18` without `cpf`). Any verifier checks it offline against the
        public key at `jwks_url` and confirms it is still in force at
        `status_url`.

        **Selectively disclosable claims:** `cpf`, `full_name`, `birth_date`,
        `over_18`, `kyc_level` (`3` = hosted session with document, liveness and
        face match; `2` = document-based identification), `method`,
        `verified_at`, `screening`. The JWT body itself carries no personal
        data - only salted digests - so the credential leaks nothing the holder
        does not disclose.

        Each call issues a **new** credential (new `id`). Earlier ones stay in
        force until they expire or the customer's status changes: a credential
        is reported `revoked` at `status_url` as soon as the customer is no
        longer approved, is frozen, or is re-verified. There is no revocation
        step for you to call. Issuance is capped per customer per day
        (`429 CREDENTIAL_ISSUANCE_LIMIT_REACHED` past the cap): keep the
        credential you received rather than requesting one per use.

        Individual customers identified by a Brazilian CPF only. Not issued in
        the sandbox environment. Availability is enabled per organization by
        OZAV; until then the call returns `422 FEATURE_NOT_AVAILABLE`.
      x-ozav-entitlement: customers
      x-ozav-scopes: [ customers:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: "`CREDENTIAL_SIGNING_UNAVAILABLE` - transient; retry with backoff."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "200":
          description: A freshly issued credential.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomerCredential" }
              examples:
                issued:
                  summary: A level-3 credential from a hosted session
                  value:
                    id: ozav_vc_Q2xhdWRlRmFibGU1MQ
                    object: credential
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    format: vc+sd-jwt
                    credential: eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK3NkLWp3dCIsImtpZCI6Im96YXYtMjAyNi0wOSJ9.eyJpc3MiOiJodHRwczovL2FwaS5vemF2LmlvIn0.MEUCIQ~WyJzYWx0Iiwib3Zlcl8xOCIsdHJ1ZV0~
                    kyc_level: 3
                    method: hosted_session
                    issued_at: 2026-09-05T12:00:00Z
                    expires_at: 2027-09-05T12:00:00Z
                    jwks_url: https://api.ozav.io/functions/v1/credencial-well-known/jwks.json
                    status_url: https://api.ozav.io/functions/v1/credencial-well-known/credential-status/ozav_vc_Q2xhdWRlRmFibGU1MQ
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/credential \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/credential", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/customers/ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1/credential",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /verifications:
    get:
      operationId: listVerifications
      tags: [ Verifications ]
      summary: List verification sessions
      description: |
        Lists verification sessions, newest first, with the standard cursor
        pagination. Filter to one customer with the `customer_id` query
        parameter to recover an existing verification id after `POST
        /verifications` or after a duplicate `VERIFICATION_ALREADY_EXISTS`
        response. Returns a `Page` of `Verification`.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ verifications:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Paginated verification sessions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Verification" }
              examples:
                page:
                  summary: One page of verifications
                  value:
                    data:
                      - id: ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: verification
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        status: pending
                        checks:
                          kyc_document: { outcome: not_started }
                          liveness: { outcome: not_started }
                        created_at: 2026-07-12T18:00:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/verifications \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/verifications", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/verifications",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
    post:
      operationId: createVerification
      tags: [ Verifications ]
      summary: Start a verification session for a customer
      description: |
        Starts one or more verification checks (hosted session when the check
        requires end-user interaction). Check kinds are the canonical OZAV
        capability taxonomy.

        Verification records persist and can be listed with `GET
        /verifications`. Hosted `session_url` issuance is gated by your
        organization's capabilities and may be absent.

        SANDBOX: a test key (`ozav_sk_test_`) NEVER receives a `session_url` and
        never reaches the identity provider - a sandbox verification has no real
        end-user to redirect. Create the verification, then drive it to its end
        state with `POST /verifications/{id}/simulate`.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ verifications:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ customer_id, capabilities ]
              properties:
                customer_id: { type: string, description: "The `ozav_cus_` id of the customer to verify." }
                capabilities:
                  type: array
                  minItems: 1
                  description: The checks to run, from the canonical capability taxonomy (see
                    `CapabilityKind`).
                  items: { $ref: "#/components/schemas/CapabilityKind" }
                redirect_url:
                  {
                    type: string,
                    format: uri,
                    description: "Where to return the end-user after a hosted verification session,
                      when one is used."
                  }
            examples:
              kyc:
                summary: Start KYC document + liveness + name screening
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  capabilities:
                    - kyc_document
                    - liveness
                    - aml_name_screening
                  redirect_url: https://acme.example/kyc/return
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Verification session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Verification" }
              examples:
                pending:
                  summary: Session created - checks not started yet
                  value:
                    id: ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: verification
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: pending
                    checks:
                      kyc_document:
                        outcome: not_started
                      liveness:
                        outcome: not_started
                      aml_name_screening:
                        outcome: not_started
                    created_at: 2026-07-12T18:00:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/verifications \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "capabilities": [
                  "kyc_document",
                  "liveness",
                  "aml_name_screening"
                ],
                "redirect_url": "https://acme.example/kyc/return"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/verifications", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "capabilities": [
                  "kyc_document",
                  "liveness",
                  "aml_name_screening"
                ],
                "redirect_url": "https://acme.example/kyc/return"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/verifications",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "capabilities": [
                        "kyc_document",
                        "liveness",
                        "aml_name_screening",
                    ],
                    "redirect_url": "https://acme.example/kyc/return",
                },
            )
            data = response.json()

  /verifications/{verification_id}:
    get:
      operationId: getVerification
      tags: [ Verifications ]
      summary: Retrieve a verification session
      description: |
        Retrieves a verification session by its `ozav_vrf_` id. Poll this after
        `POST /verifications` to watch each requested check move through its
        `CheckOutcome` and the session `status` reach `completed`.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ verifications:read ]
      x-ozav-availability: live
      parameters:
        - name: verification_id
          in: path
          required: true
          description: The opaque verification id (`ozav_vrf_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The verification session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Verification" }
              examples:
                in_progress:
                  summary: Checks running - poll until status is completed
                  value:
                    id: ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: verification
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: in_progress
                    checks:
                      kyc_document: { outcome: pass, verified_at: 2026-07-12T18:04:00Z }
                      liveness: { outcome: pending }
                      aml_name_screening: { outcome: pass, verified_at: 2026-07-12T18:04:30Z }
                    created_at: 2026-07-12T18:00:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/verifications/ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/verifications/ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/verifications/ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /verifications/{verification_id}/simulate:
    post:
      operationId: simulateVerification
      tags: [ Verifications ]
      summary: Simulate a sandbox verification result
      description: |
        Sandbox-only helper for test API keys. Marks a verification as approved
        or rejected and updates the linked customer's KYC status so downstream
        sandbox flows, including simulated wallet reads, BRL/Pix account reads,
        and card issuance, can be exercised without fake identity documents.
        Sandbox wallet and account records are projections only; they do not
        create an on-chain address or open a real bank account.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ verifications:write ]
      x-ozav-availability: live
      parameters:
        - name: verification_id
          in: path
          required: true
          description: The opaque verification id (`ozav_vrf_...`).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ outcome ]
              properties:
                outcome: { type: string, enum: [ approved, rejected ] }
                reason: { type: string, maxLength: 255 }
            examples:
              approved:
                value: { outcome: approved }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Updated verification.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Verification" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/verifications/ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1/simulate \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "outcome": "approved"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/verifications/ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1/simulate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "outcome": "approved"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/verifications/ozav_vrf_01J8Z9K2M3N4P5Q6R7S8T9U0V1/simulate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "outcome": "approved",
                },
            )

            data = response.json()

  /screenings:
    post:
      operationId: createScreening
      tags: [ Screenings ]
      summary: Screen a person, business, customer, or wallet address against
        designated lists
      description: |
        Point-in-time screening against OZAV's in-house designated-persons
        dataset (UN, OFAC, EU, UK and national sources, refreshed daily).
        Subjects may be inline (person/business) or reference an existing
        customer.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ screenings:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ subject ]
              properties:
                subject: { $ref: "#/components/schemas/ScreeningSubject" }
                lists:
                  type: array
                  description: |
                    Which designated lists to screen against. Each item is one of:
                    - `sanctions` - consolidated sanctions / designated-persons lists
                      (UN, OFAC, EU, UK and national sources). Consulted for every subject.
                    - `pep` - politically exposed persons. Consulted in-house for subjects
                      whose `country` is `BR` (the Brazilian PEP register, refreshed
                      monthly; `list_source` is `national`). For any other or missing
                      country the list cannot be consulted and the result is `review`
                      with an empty `matches` array.
                    - `adverse_media` - negative-news screening. Not consultable yet: the
                      result is `review` with an empty `matches` array.

                    Defaults to `[ sanctions ]` when omitted.
                  items: { type: string, enum: [ sanctions, pep, adverse_media ] }
                  default: [ sanctions ]
            examples:
              person:
                summary: Screen a person against sanctions + PEP
                value:
                  subject:
                    person:
                      name: Jane Example Doe
                      date_of_birth: 1980-04-12
                      country: BR
                  lists:
                    - sanctions
                    - pep
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: Screening result (synchronous when possible).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Screening" }
              examples:
                clear:
                  summary: No match
                  value:
                    id: ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: screening
                    subject:
                      person:
                        name: Jane Example Doe
                        date_of_birth: 1980-04-12
                        country: BR
                    lists:
                      - sanctions
                      - pep
                    status: completed
                    result: clear
                    matches: []
                    created_at: 2026-07-12T18:00:00Z
                match_found:
                  summary: A match - read list_source, matched_name, and strength
                  value:
                    id: ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: screening
                    subject:
                      person:
                        name: Jane Example Doe
                        date_of_birth: 1980-04-12
                        country: BR
                    lists:
                      - sanctions
                      - pep
                    status: completed
                    result: match_found
                    matches:
                      - list_source: ofac
                        matched_name: JANE E. DOE
                        strength: alias
                        list_entry_ref: SDN-12345
                    created_at: 2026-07-12T18:00:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/screenings \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "subject": {
                  "person": {
                    "name": "Jane Example Doe",
                    "date_of_birth": "1980-04-12",
                    "country": "BR"
                  }
                },
                "lists": [
                  "sanctions",
                  "pep"
                ]
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/screenings", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "subject": {
                  "person": {
                    "name": "Jane Example Doe",
                    "date_of_birth": "1980-04-12",
                    "country": "BR"
                  }
                },
                "lists": [
                  "sanctions",
                  "pep"
                ]
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/screenings",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "subject": {
                        "person": {
                            "name": "Jane Example Doe",
                            "date_of_birth": "1980-04-12",
                            "country": "BR",
                        },
                    },
                    "lists": [
                        "sanctions",
                        "pep",
                    ],
                },
            )
            data = response.json()
    get:
      operationId: listScreenings
      tags: [ Screenings ]
      summary: List screenings
      description: |
        Lists screenings, newest first, with the standard cursor pagination.
        Filter to one subject with the `customer_id` query parameter to
        reconstruct a customer's screening trail for audit. Returns a `Page` of
        `Screening`.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ screenings:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Paginated screenings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Screening" }
              examples:
                page:
                  summary: One page of screenings
                  value:
                    data:
                      - id: ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: screening
                        subject:
                          person: { name: Jane Example Doe, date_of_birth: 1980-04-12, country: BR }
                        lists: [ sanctions, pep ]
                        status: completed
                        result: clear
                        matches: []
                        created_at: 2026-07-12T18:00:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/screenings \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/screenings", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/screenings",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /screenings/{screening_id}:
    get:
      operationId: getScreening
      tags: [ Screenings ]
      summary: Retrieve a screening
      description: |
        Retrieves a single screening by its `ozav_scr_` id, including its
        `result` and any `matches`. Retain the id as evidence of the check.
      x-ozav-entitlement: compliance
      x-ozav-scopes: [ screenings:read ]
      x-ozav-availability: live
      parameters:
        - name: screening_id
          in: path
          required: true
          description: The opaque screening id (`ozav_scr_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The screening.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Screening" }
              examples:
                match_found:
                  summary: A match - read list_source, matched_name, and strength
                  value:
                    id: ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: screening
                    subject:
                      person: { name: Jane Example Doe, date_of_birth: 1980-04-12, country: BR }
                    lists: [ sanctions ]
                    status: completed
                    result: match_found
                    matches:
                      - list_source: ofac
                        matched_name: JANE E. DOE
                        strength: alias
                        list_entry_ref: SDN-12345
                    created_at: 2026-07-12T18:00:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/screenings/ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/screenings/ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/screenings/ozav_scr_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /quotes:
    post:
      operationId: createQuote
      tags: [ Quotes ]
      summary: Create an FX quote
      description: |
        Quotes are opaque, short-lived, and carry the OZAV all-in client-facing
        rate. When enabled, they also carry the additional client-selected
        spread in basis points. A quote is executed by creating a transfer that
        references it.

        **Setting your own spread (revenue share).** Pass the optional
        `spread_bps` to add basis points on top of the OZAV all-in rate for
        this quote. This value is your additional client-selected spread; it
        rides the quote through to settlement and is echoed back as
        `exchange_conditions.spread_bps`. OZAV cost, floor, margin, and split
        analytics are internal and are never exposed in the public response.
        Availability of client-selected spread is gated on enablement for your
        organization.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ quotes:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ customer_id, source, target ]
              properties:
                customer_id:
                  type: string
                  description: The opaque customer id (ozav_cus_...). Resolved scoped to the
                    caller; an unknown or cross-tenant id returns 404.
                source:
                  type: object
                  required: [ currency ]
                  properties:
                    currency: { $ref: "#/components/schemas/Currency" }
                    amount: { $ref: "#/components/schemas/DecimalAmount" }
                target:
                  type: object
                  required: [ currency ]
                  properties:
                    currency: { $ref: "#/components/schemas/Currency" }
                    amount: { $ref: "#/components/schemas/DecimalAmount" }
                source_rail: { $ref: "#/components/schemas/Rail" }
                destination_rail: { $ref: "#/components/schemas/Rail" }
                spread_bps:
                  type: integer
                  minimum: 0
                  maximum: 10000
                  description: |
                    Optional. Additional client-selected spread for this quote,
                    in integer basis points. Available only when enabled for
                    your organization. The value is applied internally on top of
                    the OZAV all-in rate and echoed back as
                    `exchange_conditions.spread_bps`. A non-integer, negative,
                    or out-of-range value is rejected with 400.
                funding_source:
                  type: string
                  enum: [ incoming_transfer, named_account_balance ]
                  description: |
                    Optional. How the source fiat is funded. `incoming_transfer`
                    includes the measured inbound cost (USD SWIFT US$ 35;
                    EUR SEPA / GBP Faster Payments US$ 5, deducted from the
                    credit). `named_account_balance` converts funds already on
                    the named account and includes the outbound cost to the
                    desk (USD wire US$ 35 + 12 bps; EUR/GBP local US$ 5 + 12
                    bps, charged on top). Omitted behaves as
                    `incoming_transfer` for USD, EUR and GBP.
                facility_preview:
                  type: boolean
                  description: >-
                    Ask for the **FX rate lock conditions** alongside the quote.
                    When `true` and your organization has the facility
                    contracted, the response carries an extra `facility` block;
                    `POST /transfers` then accepts its `terms_version` as
                    `facility_terms_version`. This is the ONLY place that value
                    comes from.


                    ⚠️ **Silent by design when unavailable.** A client without
                    the facility gets a normal `201` quote with no `facility`
                    block and no error - the preview never fails a quote,
                    because the quote is the product. Read the ABSENCE of the
                    block as "not available for this account", and use `POST
                    /transfers` (which refuses loudly with `422
                    FACILITY_LOCK_UNAVAILABLE` and a `details.reason`) when you
                    need to know why.


                    ⚠️ **It prices, it does not reserve.** `capacity_guaranteed`
                    is always `false`: room under the shared ceiling is resolved
                    at acceptance, inside the lock. A preview that promised
                    capacity would be a promise the design cannot honour.
              description: Provide exactly one of source.amount or target.amount.
            examples:
              brl_to_usd_with_revenue_share:
                summary: BRL → USD with 40 bps of additional client spread
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  source:
                    currency: BRL
                    amount: "50000.00"
                  target:
                    currency: USD
                  spread_bps: 40
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: The quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
              examples:
                quote:
                  summary: "OZAV all-in quote with additional client spread"
                  value:
                    id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: quote
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    rate_type: floating
                    rate: "0.1881"
                    source:
                      amount: "50000.00"
                      currency: BRL
                    target:
                      amount: "9405.00"
                      currency: USD
                    exchange_conditions:
                      spread_bps: 40
                    expires_at: 2026-07-12T18:05:00Z
                    created_at: 2026-07-12T18:00:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/quotes \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "source": {
                  "currency": "BRL",
                  "amount": "50000.00"
                },
                "target": {
                  "currency": "USD"
                },
                "spread_bps": 40
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await fetch("https://developers.ozav.io/v2/quotes",
            {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "source": {
                  "currency": "BRL",
                  "amount": "50000.00"
                },
                "target": {
                  "currency": "USD"
                },
                "spread_bps": 40
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/quotes",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "source": {
                        "currency": "BRL",
                        "amount": "50000.00",
                    },
                    "target": {
                        "currency": "USD",
                    },
                    "spread_bps": 40,
                },
            )
            data = response.json()

  /quotes/{quote_id}:
    get:
      operationId: getQuote
      tags: [ Quotes ]
      summary: Retrieve a quote
      description: |
        Retrieves a quote by its `ozav_q_` id, including its `rate`,
        `exchange_conditions`, and `expires_at`. Quotes are short-lived - a quote
        past its expiry can no longer be executed into a transfer.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ quotes:read ]
      x-ozav-availability: live
      parameters:
        - name: quote_id
          in: path
          required: true
          description: The opaque quote id (`ozav_q_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The quote.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
              examples:
                quote:
                  summary: A BRL → USD quote
                  value:
                    id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: quote
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    rate_type: floating
                    rate: "0.1881"
                    source: { amount: "50000.00", currency: BRL }
                    target: { amount: "9405.00", currency: USD }
                    exchange_conditions:
                      spread_bps: 40
                    expires_at: 2026-07-12T18:05:00Z
                    created_at: 2026-07-12T18:00:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/quotes/ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/quotes/ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/quotes/ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /transactions:
    get:
      operationId: listTransactions
      tags: [ Transactions ]
      summary: List every movement in one timeline
      description: |
        One feed for everything that moved value for your customers: the
        transfers you created through this API, and the on-chain deposits
        credited into custody. You no longer have to stitch two listings together
        and guess the order between them.

        Ordered newest first. Each row carries a `type` (`transfer` or
        `deposit`), so you can branch without a second lookup, and the `id` is
        the id of the underlying resource - use it to fetch the full record.

        Paginated with the standard opaque cursor. The cursor spans BOTH sources
        at once: it is not one cursor per source, so a busy day on one side can
        never hide rows from the other.

        **Card transactions are not in this feed.** They are read per card at
        `GET /cards/{card_id}/transactions`.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: |
            Filter to a single customer by its opaque `ozav_cus_` id. Applies to
            both sources: transfers created for that customer, and the deposits
            credited to it.

            An id that does not exist, or belongs to another organization or
            another environment, returns an empty page rather than `404` - the
            same behaviour as `GET /transfers`, so a filter never tells you
            whether an id exists somewhere you cannot see.
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
        "200":
          description: A page of movements, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [ data, has_more, next_cursor ]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Transaction" }
                  has_more: { type: boolean }
                  next_cursor: { type: [ string, "null" ] }
              examples:
                mixed:
                  summary: A transfer and an on-chain deposit in one timeline
                  value:
                    data:
                      - object: transaction
                        id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        type: transfer
                        status: completed
                        amount: "50000.00"
                        currency: BRL
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        created_at: 2026-07-12T18:01:00Z
                      - object: transaction
                        id: 6f499bc1-bf2a-49ef-a1f2-86ec5acc48d1
                        type: deposit
                        status: credited
                        amount: "250.000000"
                        currency: USDC
                        network: base
                        created_at: 2026-07-11T09:20:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transactions \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/transactions", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/transactions",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /transfers:
    post:
      operationId: createTransfer
      tags: [ Transfers ]
      summary: Create a transfer
      description: |
        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         |
        | `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` entitlement and is subject to the two controls below.

        **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 a provider-neutral 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.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:write ]
      x-ozav-availability: live
      # ⚠️ GERADA de `_shared/v2/transfer-flow-availability.ts`. `check-v2-transfer-flow-parity.mjs`
      # reprova se as duas discordarem. Ate 18-ago esta tabela vivia so aqui, escrita a mao, e tinha
      # DUAS de seis linhas erradas - em sentidos opostos, medidas contra a API viva.
      #
      # `live`        - executa hoje.
      # `gated`       - a rota executa; falta um portao que NAO e do integrador (ver `blocked_by`).
      # `coming_soon` - recusado de proposito; ha codigo a escrever antes.
      x-ozav-transfer-flow-availability:
        fiat_payment__wallet: live
        fiat_payment__payout_destination: live
        wallet__payout_destination: live
        wallet__wallet: live
        wallet__crypto_address: live
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ customer_id, source, destination ]
              description: >
                Provide `amount` when `quote_id` is omitted (same-currency moves
                carry the amount here); when `quote_id` is present the quote is
                authoritative and `amount` is rejected. Exactly one of the two
                is required.
              properties:
                customer_id:
                  {
                    type: string,
                    description: "The `ozav_cus_` id of the customer this transfer is for."
                  }
                quote_id:
                  {
                    type: string,
                    description: "The `ozav_q_` id of the quote to execute. Required when source and
                      target currencies differ; authoritative for the amount
                      when present."
                  }
                amount:
                  allOf:
                    - $ref: "#/components/schemas/TransferMonetaryAmount"
                  description: >
                    Amount to move, for the no-quote same-currency path.
                    Required when `quote_id` is absent; rejected when present.
                source: { $ref: "#/components/schemas/TransferSource" }
                destination: { $ref: "#/components/schemas/TransferDestination" }
                travel_rule: { $ref: "#/components/schemas/TravelRule" }
                metadata: { $ref: "#/components/schemas/Metadata" }
                facility_terms_accepted:
                  type: boolean
                  description: >-
                    **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_version:
                  type: string
                  minLength: 1
                  description: >-
                    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.
            examples:
              brl_pix_onramp:
                summary: BRL / Pix on-ramp into a stablecoin wallet
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  quote_id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  source:
                    type: fiat_payment
                    currency: BRL
                    rail: pix
                  destination:
                    type: wallet
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "201":
          description: Transfer created.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
              examples:
                created:
                  summary: Transfer created - poll status; source_leg/destination_leg settle
                    independently
                  value:
                    id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transfer
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    quote_id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: awaiting_payment
                    source:
                      type: fiat_payment
                      currency: BRL
                      rail: pix
                      payment_instructions:
                        rail: pix
                        intent_id: 6f499bc1-bf2a-49ef-a1f2-86ec5acc48d1
                        br_code: "00020126580014br.gov.bcb.pix..."
                        qr_code_image: "data:image/png;base64,iVBORw0KGgo..."
                        expires_at: 2026-07-12T18:16:00Z
                    destination:
                      type: wallet
                      wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    source_leg:
                      status: pending
                    destination_leg:
                      status: pending
                    amounts:
                      source:
                        amount: "50000.00"
                        currency: BRL
                      target:
                        amount: "9405.000000"
                        currency: USDT
                      rate: "0.1881"
                    exchange_conditions:
                      spread_bps: 40
                    created_at: 2026-07-12T18:01:00Z
                    updated_at: 2026-07-12T18:01:00Z
        "200":
          description: >
            DEGRADED SUCCESS - the transfer was dispatched, but finalizing the
            record afterwards failed (projection persist, owner binding, or
            serialization). The provider MAY have acted, 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.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [ id, object, status ]
                properties:
                  id: { type: string, description: "The transfer id - use it to reconcile." }
                  object: { type: string, const: transfer }
                  status: { type: string, const: needs_review }
              examples:
                needs_review:
                  summary: Dispatched, but the record could not be finalized - reconcile with GET
                  value:
                    id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transfer
                    status: needs_review
        "202": { $ref: "#/components/responses/IdempotencyProcessing" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422":
          description: >
            Well-formed but not executable. Codes: NOT_AVAILABLE (the selected
            non-BRL/Pix wallet or crypto leg is not available yet),
            TARGET_CURRENCY_NOT_AVAILABLE, UNSUPPORTED_COMBINATION (the
            source/destination pair is not defined), RAIL_NOT_AVAILABLE (no
            settlement-ready coverage for the requested rail/corridor),
            QUOTE_EXPIRED (request a new quote), CUSTOMER_NOT_ENABLED (the
            payout destination is not yet enabled for transfers),
            PROVISIONING_REQUIRED (the customer or destination is not yet
            provisioned at the settlement layer),
            FACILITY_TERMS_VERSION_REQUIRED (the optional FX rate lock was
            accepted without `facility_terms_version`), and
            FACILITY_LOCK_UNAVAILABLE (the optional FX rate lock was refused by
            account state; nothing was created, and `details.reason` names the
            refusal - branch on it, not on the message).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
              examples:
                quote_expired:
                  summary: The referenced quote is no longer valid
                  value:
                    error:
                      type: invalid_request_error
                      code: QUOTE_EXPIRED
                      message: The quote has expired. Request a new quote before creating the
                        transfer.
                      request_id: req_01J8Z9K2M3N4P5Q6R7S8T9U0V1
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "quote_id": "ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "source": {
                  "type": "fiat_payment",
                  "currency": "BRL",
                  "rail": "pix"
                },
                "destination": {
                  "type": "wallet",
                  "wallet_id": "ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/transfers", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "quote_id": "ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "source": {
                  "type": "fiat_payment",
                  "currency": "BRL",
                  "rail": "pix"
                },
                "destination": {
                  "type": "wallet",
                  "wallet_id": "ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1"
                }
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/transfers",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "quote_id": "ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "source": {
                        "type": "fiat_payment",
                        "currency": "BRL",
                        "rail": "pix",
                    },
                    "destination": {
                        "type": "wallet",
                        "wallet_id": "ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    },
                },
            )
            data = response.json()
    get:
      operationId: listTransfers
      tags: [ Transfers ]
      summary: List transfers
      description: |
        Lists transfers, newest first, with the standard cursor pagination.
        Filter by `customer_id` and/or `status` (a `TransferStatus` value).
        Returns a `Page` of `Transfer`. BRL/Pix paths create real transfer
        records; combinations whose settlement rails are not enabled still fail
        closed before a transfer is persisted.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
        - name: status
          in: query
          description: Filter transfers by their current `TransferStatus`.
          schema: { $ref: "#/components/schemas/TransferStatus" }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Paginated transfers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Transfer" }
              examples:
                page:
                  summary: One page of transfers
                  value:
                    data:
                      - id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: transfer
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        quote_id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        status: completed
                        source: { type: fiat_payment, currency: BRL, rail: pix }
                        destination:
                          {
                            type: payout_destination,
                            payout_destination_id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                          }
                        source_leg: { status: completed, settled_at: 2026-07-12T18:06:00Z }
                        destination_leg: { status: completed, settled_at: 2026-07-12T18:06:30Z }
                        amounts:
                          source: { amount: "50000.00", currency: BRL }
                          target: { amount: "9405.00", currency: USD }
                          rate: "0.1881"
                        created_at: 2026-07-12T18:01:00Z
                        updated_at: 2026-07-12T18:06:30Z
                        completed_at: 2026-07-12T18:06:30Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/transfers", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/transfers",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /transfers/{transfer_id}:
    parameters:
      - name: transfer_id
        in: path
        required: true
        description: The opaque transfer id (`ozav_tr_...`).
        schema: { type: string }
    get:
      operationId: getTransfer
      tags: [ Transfers ]
      summary: Retrieve a transfer
      description: |
        Retrieves a transfer by its `ozav_tr_` id, including its `status` and the
        independent `source_leg` / `destination_leg` settlement states. Poll this
        to track a transfer to completion. BRL/Pix paths create real
        transfer records; other combinations may remain gated.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The transfer with per-leg settlement status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
              examples:
                in_flight:
                  summary: Payment confirmed, payout not yet initiated
                  value:
                    id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transfer
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    quote_id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: payment_confirmed
                    source: { type: fiat_payment, currency: BRL, rail: pix }
                    destination:
                      {
                        type: payout_destination,
                        payout_destination_id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                      }
                    source_leg: { status: completed }
                    destination_leg: { status: pending }
                    amounts:
                      source: { amount: "50000.00", currency: BRL }
                      target: { amount: "9405.00", currency: USD }
                      rate: "0.1881"
                    created_at: 2026-07-12T18:01:00Z
                    updated_at: 2026-07-12T18:03:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    patch:
      operationId: updateTransfer
      tags: [ Transfers ]
      summary: Update mutable transfer attributes (metadata)
      description: |
        Updates the mutable attributes of a transfer - currently just
        `metadata`. Does not alter money movement. Returns the updated
        `Transfer`.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                metadata: { $ref: "#/components/schemas/Metadata" }
            examples:
              metadata:
                summary: Attach your own reference
                value:
                  metadata:
                    invoice: INV-2026-0042
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Updated transfer.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
              examples:
                metadata_attached:
                  summary: Metadata attached to the transfer
                  value:
                    id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transfer
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    quote_id: ozav_q_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: completed
                    source: { type: fiat_payment, currency: BRL, rail: pix }
                    destination:
                      {
                        type: payout_destination,
                        payout_destination_id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                      }
                    amounts:
                      source: { amount: "50000.00", currency: BRL }
                      target: { amount: "9405.00", currency: USD }
                      rate: "0.1881"
                    created_at: 2026-07-12T18:01:00Z
                    updated_at: 2026-07-12T18:07:00Z
                    metadata: { invoice: INV-2026-0042 }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "metadata": {
                  "invoice": "INV-2026-0042"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "metadata": {
                  "invoice": "INV-2026-0042"
                }
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.patch(
                "https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "metadata": {
                        "invoice": "INV-2026-0042",
                    },
                },
            )

            data = response.json()

  /transfers/{transfer_id}/documents:
    post:
      operationId: uploadTransferDocument
      tags: [ Transfers ]
      summary: Attach the supporting document (invoice) for a payout
      description: |
        Attaches the commercial supporting document for a payout transfer: in
        practice the invoice the payment settles. The receiving institution
        validates the purpose code against this document, so a payout can be
        accepted and then sit unsettled while it is missing.

        One document per transfer. Upload it before the payout is dispatched:
        the document is forwarded to the receiving institution only after the
        payment order exists, so `provider_status` is `null` here and is not a
        promise that it has been forwarded.

        The bytes travel inline as base64 in a single call, unlike customer
        documents, which use a two-call signed upload. A payout document is one
        small file per operation, and a single call makes the result
        indivisible, with no window where the document is registered and its
        bytes are absent.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:write ]
      x-ozav-availability: live
      parameters:
        - name: transfer_id
          in: path
          required: true
          description: The opaque transfer id (`ozav_tr_...`).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PayoutDocumentUpload" }
            examples:
              invoice_pdf:
                summary: The invoice the payout settles
                value:
                  file_name: invoice-2026-08.pdf
                  content_type: application/pdf
                  content_base64: JVBERi0xLjQKJeLjz9MK
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Document stored and linked to the payout.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutDocument" }
              examples:
                stored:
                  summary: Stored, not yet forwarded
                  value:
                    id: 3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607
                    object: payout_document
                    transfer_id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    file_name: invoice-2026-08.pdf
                    content_type: application/pdf
                    size_bytes: 20
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This transfer already has a supporting document.
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "file_name": "invoice-2026-08.pdf",
                "content_type": "application/pdf",
                "content_base64": "JVBERi0xLjQKJeLjz9MK"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "file_name": "invoice-2026-08.pdf",
                "content_type": "application/pdf",
                "content_base64": "JVBERi0xLjQKJeLjz9MK"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/documents",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "file_name": "invoice-2026-08.pdf",
                    "content_type": "application/pdf",
                    "content_base64": "JVBERi0xLjQKJeLjz9MK",
                },
            )

            data = response.json()

  /transfers/{transfer_id}/travel-rule:
    post:
      operationId: submitTravelRule
      tags: [ Transfers ]
      summary: Submit travel-rule data for a transfer
      description: |
        Attaches originator/beneficiary travel-rule information to a transfer
        whose destination is an external crypto address. Required by policy above
        a server-enforced amount threshold; it may also be supplied inline on
        `POST /transfers`. Returns the updated `Transfer`. Crypto-address
        transfer legs remain gated until enabled.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:write ]
      x-ozav-availability: live
      parameters:
        - name: transfer_id
          in: path
          required: true
          description: The opaque transfer id (`ozav_tr_...`).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TravelRule" }
            examples:
              originator_beneficiary:
                summary: Originator + beneficiary for a crypto send
                value:
                  originator:
                    name: Acme Ltda
                    account: "0xA97b1f0c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70"
                  beneficiary:
                    name: Jane Roe
                    account: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Travel-rule data accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
              examples:
                accepted:
                  summary: Travel-rule data recorded on the transfer
                  value:
                    id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transfer
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: awaiting_payment
                    source: { type: fiat_payment, currency: BRL, rail: pix }
                    destination:
                      {
                        type: payout_destination,
                        payout_destination_id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                      }
                    amounts:
                      source: { amount: "50000.00", currency: BRL }
                      target: { amount: "9405.00", currency: USD }
                      rate: "0.1881"
                    travel_rule:
                      originator: { name: Acme Ltda, account: "0xA97b1f0c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70" }
                      beneficiary: { name: Jane Roe, account: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8" }
                    created_at: 2026-07-12T18:01:00Z
                    updated_at: 2026-07-12T18:02:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/travel-rule \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "originator": {
                  "name": "Acme Ltda",
                  "account": "0xA97b1f0c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70"
                },
                "beneficiary": {
                  "name": "Jane Roe",
                  "account": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/travel-rule", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "originator": {
                  "name": "Acme Ltda",
                  "account": "0xA97b1f0c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70"
                },
                "beneficiary": {
                  "name": "Jane Roe",
                  "account": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                }
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/travel-rule",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "originator": {
                        "name": "Acme Ltda",
                        "account": "0xA97b1f0c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70",
                    },
                    "beneficiary": {
                        "name": "Jane Roe",
                        "account": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                    },
                },
            )

            data = response.json()

  /transfers/{transfer_id}/simulate:
    post:
      operationId: simulateTransferPayment
      tags: [ Transfers ]
      summary: Confirm the pay-in for a sandbox transfer
      description: |
        Sandbox affordance. Confirms the incoming payment on a transfer that is
        sitting in `awaiting_payment`, so you can drive the state machine past
        the one step no test key can reach on its own: in `test` there is no
        real payer and no provider callback, so an on-ramp would otherwise wait
        in `awaiting_payment` until it expires.

        The transfer advances through BOTH legs, in two steps, exactly as a real
        confirmed payment would: the pay-in leg settles (`payment_confirmed`,
        emitting `transfer.updated`), then the destination leg settles and the
        transfer reaches a terminal `completed`, emitting `transfer.completed`.
        Both events are delivered and both appear on `GET /events`, in that
        order - the response body carries the terminal state. The wallet balance
        on `GET /wallets/{wallet_id}/balances` moves with it.

        **Until 8 September 2026 this route advanced only the pay-in leg**, and
        this paragraph described that as the design. It was not: an on-ramp
        stopped at `payment_confirmed` with `destination_leg: pending` and
        nothing ever finished it. Measured in production on that date, none of
        the 54 sandbox on-ramps ever left that state. If you built a flow around
        the old wording, the change to expect is that a simulated on-ramp is now
        terminal and fires a second webhook.

        One case still stops at `payment_confirmed`, by design: a transfer whose
        opening leg was settled by a REAL rail rather than the sandbox executor.
        Fabricating a completion there would claim money arrived and was
        delivered when neither happened.

        **Only on-ramp and fx transfers can be simulated**, because they are the
        only ones with an incoming payment to confirm. An off-ramp opens on its
        payout leg and a wallet transfer is a custody move, so neither has a
        payer to simulate; both are refused with `409
        TRANSFER_NOT_AWAITING_PAYMENT`, and so is a transfer that has already
        moved past `awaiting_payment`. Create a new transfer to test another
        outcome.

        **Test keys only.** A live key is refused with `403 SANDBOX_ONLY`; the
        environment comes from the key prefix (`ozav_sk_test_` vs
        `ozav_sk_live_`), never from the URL.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ transfers:write ]
      x-ozav-availability: live
      parameters:
        - name: transfer_id
          in: path
          required: true
          description: The opaque transfer id (`ozav_tr_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: The pay-in was confirmed and the transfer advanced.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transfer" }
              examples:
                confirmed:
                  summary: Sandbox on-ramp advanced to completed
                  # O exemplo carregava `destination.type: payout_destination`, forma de `fx` sob
                  # um rotulo de on-ramp. Um on-ramp entrega numa CARTEIRA.
                  value:
                    id: ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transfer
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: completed
                    source: { type: fiat_payment, currency: BRL, rail: pix }
                    destination: { type: wallet, wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1 }
                    amounts:
                      source: { amount: "500.00", currency: BRL }
                      target: { amount: "89.55", currency: USDC }
                      rate: "0.1791"
                    source_leg: { status: completed, settled_at: 2026-07-12T18:03:00Z }
                    destination_leg: { status: completed, settled_at: 2026-07-12T18:03:00Z }
                    created_at: 2026-07-12T18:01:00Z
                    updated_at: 2026-07-12T18:03:00Z
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/simulate \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/simulate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/simulate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /accounts:
    post:
      operationId: createAccount
      tags: [ Accounts ]
      summary: Request a customer's named fiat account
      description: |
        Creates or returns a customer's named fiat receiving account through one
        provider-neutral OZAV resource. Use the same endpoint for BRL/Pix today
        and for future USD, EUR, GBP, MXN, COP, or other account rails as they
        are enabled.

        The customer must already be approved. In sandbox, BRL/BR/pix
        materializes a simulated account with deterministic Pix and
        bank-account instructions; no real bank account is opened and no real
        funds can move. Every corridor OZAV serves uses this same
        contract, and a corridor OZAV cannot currently open answers a NAMED
        error rather than a partial account, so your integration learns the
        outcome from the response instead of from a status label. The public
        response never exposes the banking venue behind the account.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountCreateRequest" }
            examples:
              brl_pix:
                summary: BRL/Pix named account
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  account_type: named_fiat_account
                  currency: BRL
                  country: BR
                  rail: pix
                  metadata:
                    label: primary
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Account created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
              examples:
                sandbox_active:
                  summary: Sandbox account
                  value:
                    id: ozav_acc_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: account
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    account_type: named_fiat_account
                    currency: BRL
                    country: BR
                    rail: pix
                    status: active
                    holder:
                      name: Ana Ramos
                      type: individual
                    receiving_instructions:
                      pix:
                        key: sandbox+01j8z9k2m3n4p5q6r7s8t9u0@developers.ozav.io
                        key_type: email
                      bank_account:
                        branch: "0001"
                        account_number: "9123456789-5"
                        account_type: payment
                    capabilities:
                      receive: true
                      send: false
                    balance: null
                    metadata:
                      label: primary
                    created_at: 2026-07-22T12:00:00Z
                    updated_at: 2026-07-22T12:00:00Z
        "200":
          description: An account already exists for this customer and was returned
            idempotently.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/accounts \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "account_type": "named_fiat_account",
                "currency": "BRL",
                "country": "BR",
                "rail": "pix",
                "metadata": {
                  "label": "primary"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/accounts", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "account_type": "named_fiat_account",
                "currency": "BRL",
                "country": "BR",
                "rail": "pix",
                "metadata": {
                  "label": "primary"
                }
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/accounts",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "account_type": "named_fiat_account",
                    "currency": "BRL",
                    "country": "BR",
                    "rail": "pix",
                    "metadata": {
                        "label": "primary",
                    },
                },
            )
            data = response.json()
    get:
      operationId: listAccounts
      tags: [ Accounts ]
      summary: List named accounts
      description: |
        Lists named fiat accounts with standard cursor pagination. Filter with
        `customer_id`, `currency`, `country`, `rail`, or `status` to recover the
        account created after sandbox KYC approval or live opening. Returns
        only accounts visible in the current key environment.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
        - name: currency
          in: query
          description: Filter by account currency.
          schema: { $ref: "#/components/schemas/Currency" }
        - name: country
          in: query
          description: Filter by account country or region code.
          schema: { type: string, pattern: "^[A-Z]{2}$" }
        - name: rail
          in: query
          description: Filter by receiving rail.
          schema: { $ref: "#/components/schemas/Rail" }
        - name: status
          in: query
          description: Filter by public account status.
          schema: { $ref: "#/components/schemas/AccountStatus" }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Paginated accounts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/accounts \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/accounts", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/accounts",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /accounts/{account_id}:
    get:
      operationId: getAccount
      tags: [ Accounts ]
      summary: Retrieve a named account
      description: |
        Retrieves a named fiat account by its opaque `ozav_acc_` id. The
        account must belong to the caller and match the current key environment.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:read ]
      x-ozav-availability: live
      parameters:
        - name: account_id
          in: path
          required: true
          description: The opaque account id (`ozav_acc_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/accounts/{account_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/accounts/{account_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/accounts/{account_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /accounts/{account_id}/movements:
    get:
      operationId: listAccountMovements
      tags: [ Accounts ]
      summary: List an account's statement
      description: |
        Lists the credits that arrived in a named fiat account, newest first, as the
        account's institution reports them. The statement is read live at request time,
        not from an OZAV copy, so it can lag the institution by a few seconds and it is
        never older than the account's `balance.as_of`.

        Each item is a credit into the account. `reference` is the text the payer sent
        with the transfer, when the rail carries one; `received` is present only when the
        institution reports the amount that actually landed separately from the amount
        the payer sent (international wires lose correspondent charges on the way).

        `501 ACCOUNT_MOVEMENTS_NOT_AVAILABLE` means this account's institution does not
        expose a statement through OZAV (sandbox accounts included); `503
        ACCOUNT_MOVEMENTS_UNAVAILABLE` means it does, and the read failed - retry.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:read ]
      x-ozav-availability: live
      parameters:
        - name: account_id
          in: path
          required: true
          description: The opaque account id (`ozav_acc_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The account's credits, newest first. `has_more` is always `false`
            today - the full page the institution returns is served.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [ data, has_more, next_cursor ]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AccountMovement" }
                  has_more: { type: boolean }
                  next_cursor:
                    type: [ string, "null" ]
              example:
                data:
                  - id: "9f2c1d"
                    object: account_movement
                    direction: credit
                    amount: "965.00"
                    currency: USD
                    occurred_at: 2026-09-05T10:00:00Z
                    reference: "PAYMENT REF INVOICE 77"
                    payer:
                      name: ACME LLC
                    received:
                      amount: "930.00"
                      currency: USD
                has_more: false
                next_cursor: null
        "404": { $ref: "#/components/responses/NotFound" }
        "501": { $ref: "#/components/responses/NotImplementedYet" }
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/accounts/{account_id}/movements \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/accounts/{account_id}/movements", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/accounts/{account_id}/movements",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /accounts/{account_id}/deactivate:
    post:
      operationId: deactivateAccount
      tags: [ Accounts ]
      summary: Deactivate a sandbox account
      description: |
        Suspends a sandbox BRL/Pix account so you can test inactive-account
        handling. Live account lifecycle changes remain gated until the
        operating procedure for your organization is enabled.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:write ]
      x-ozav-availability: live
      parameters:
        - name: account_id
          in: path
          required: true
          description: The opaque account id (`ozav_acc_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Account deactivated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl https://developers.ozav.io/v2/accounts/{account_id}/deactivate
            \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/accounts/{account_id}/deactivate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/accounts/{account_id}/deactivate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /accounts/{account_id}/reactivate:
    post:
      operationId: reactivateAccount
      tags: [ Accounts ]
      summary: Reactivate a sandbox account
      description: |
        Reactivates a sandbox BRL/Pix account after a deactivate test. Live
        account lifecycle changes remain gated until the operating procedure
        for your organization is enabled.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:write ]
      x-ozav-availability: live
      parameters:
        - name: account_id
          in: path
          required: true
          description: The opaque account id (`ozav_acc_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Account reactivated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl https://developers.ozav.io/v2/accounts/{account_id}/reactivate
            \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/accounts/{account_id}/reactivate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/accounts/{account_id}/reactivate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /accounts/{account_id}/payouts:
    post:
      operationId: createAccountPayout
      tags: [ Accounts ]
      summary: Pay out from a named account to a payout destination
      description: |
        Sends fiat from the customer's own named account to one of that
        customer's registered payout destinations, in the account's currency.
        No conversion happens on this endpoint: the destination must be in the
        same currency as the account (use `POST /transfers` to convert first).

        The payment leaves **the customer's account**, with the customer as the
        sender of record. It is refused, with a named code, when the account
        cannot send yet (`422 ACCOUNT_NOT_READY`), when the customer profile
        lacks fields the payment rail requires
        (`422 ACCOUNT_PAYOUT_SENDER_INCOMPLETE`), when the destination belongs to
        a different customer (`422 PAYOUT_DESTINATION_NOT_OWNED`), or when the
        amount is above the per-payout limit for your organization
        (`422 ACCOUNT_PAYOUT_ABOVE_LIMIT`). Payouts from named accounts are
        enabled per organization: until yours is, the endpoint answers
        `403 ACCOUNT_PAYOUT_NOT_ENABLED`.

        **`reference` is the idempotency lever.** Repeating a request with the
        same `reference` and `amount` for the same account returns the existing
        payout with `200` instead of sending a second payment. It also travels
        to the receiving bank as the remittance information.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ accounts:write ]
      x-ozav-availability: live
      parameters:
        - name: account_id
          in: path
          required: true
          description: The opaque account id (`ozav_acc_...`).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountPayoutCreateRequest" }
            examples:
              usdToOwnBank:
                summary: Pay USD from the customer's account to their registered bank
                value:
                  amount: "50.00"
                  destination:
                    type: payout_destination
                    payout_destination_id: ozav_pd_01J00000000000000000000000
                  reference: "INV 0042"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Payout accepted by the payment institution and recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountPayout" }
        "200":
          description: The same payout already existed (same `reference` and `amount`);
            nothing was sent again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountPayout" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/accounts/{account_id}/payouts \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "amount": "50.00",
                "destination": {
                  "type": "payout_destination",
                  "payout_destination_id": "ozav_pd_01J00000000000000000000000"
                },
                "reference": "INV 0042"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/accounts/{account_id}/payouts",
            {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "amount": "50.00",
                "destination": {
                  "type": "payout_destination",
                  "payout_destination_id": "ozav_pd_01J00000000000000000000000"
                },
                "reference": "INV 0042"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/accounts/{account_id}/payouts",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "amount": "50.00",
                    "destination": {
                        "type": "payout_destination",
                        "payout_destination_id": "ozav_pd_01J00000000000000000000000",
                    },
                    "reference": "INV 0042",
                },
            )

            data = response.json()

  /payout-destinations:
    post:
      operationId: createPayoutDestination
      tags: [ Payout Destinations ]
      summary: Register a payout destination
      description: |
        One canonical resource for every payout rail. `details` is a
        discriminated union keyed by `rail`. BRL/Pix destinations use a Pix key
        (`details.pix_key`) and are stored as provider-neutral OZAV payout
        destinations. Do not submit a Pix BR code here: BR codes are
        pay-in instructions for a specific charge, not reusable payout
        destinations. Rails that require external venue provisioning may still
        fail closed until enabled.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ destinations:write ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyOptional"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PayoutDestinationCreateRequest" }
            examples:
              pix:
                summary: BRL / PIX - a PIX key
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  currency: BRL
                  rail: pix
                  account_holder:
                    name: Ana Ramos
                    type: individual
                  details:
                    pix_key: ana@example.com
              ach:
                summary: USD / ACH - NOT SERVED, kept to show the refusal
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  currency: USD
                  rail: ach
                  account_holder:
                    name: Acme Inc
                    type: business
                  details:
                    account_number: "000123456789"
                    routing_number: "026009593"
                    account_type: checking
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Payout destination registered.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutDestination" }
              examples:
                pix_active:
                  summary: Registered and active
                  value:
                    id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: payout_destination
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    currency: BRL
                    rail: pix
                    status: active
                    account_holder:
                      name: Ana Ramos
                      type: individual
                    details:
                      pix_key: ana@example.com
                    created_at: 2026-07-12T18:05:00Z
        "202": { $ref: "#/components/responses/IdempotencyProcessing" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "501": { $ref: "#/components/responses/NotImplementedYet" }
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/payout-destinations \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "currency": "BRL",
                "rail": "pix",
                "account_holder": {
                  "name": "Ana Ramos",
                  "type": "individual"
                },
                "details": {
                  "pix_key": "ana@example.com"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/payout-destinations", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "currency": "BRL",
                "rail": "pix",
                "account_holder": {
                  "name": "Ana Ramos",
                  "type": "individual"
                },
                "details": {
                  "pix_key": "ana@example.com"
                }
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/payout-destinations",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "currency": "BRL",
                    "rail": "pix",
                    "account_holder": {
                        "name": "Ana Ramos",
                        "type": "individual",
                    },
                    "details": {
                        "pix_key": "ana@example.com",
                    },
                },
            )
            data = response.json()
    get:
      operationId: listPayoutDestinations
      tags: [ Payout Destinations ]
      summary: List payout destinations
      description: |
        Lists a customer's payout destinations with the standard cursor
        pagination; filter with the `customer_id` query parameter. Returns a
        `Page` of `PayoutDestination`. BRL/Pix key destinations can be created
        and read; externally provisioned rails may still be gated.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ destinations:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Paginated payout destinations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/PayoutDestination" }
              examples:
                page:
                  summary: One page of payout destinations
                  value:
                    data:
                      - id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: payout_destination
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        currency: BRL
                        rail: pix
                        status: active
                        account_holder: { name: Ana Ramos, type: individual }
                        details: { pix_key: ana@example.com }
                        created_at: 2026-07-12T18:05:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/payout-destinations \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/payout-destinations", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/payout-destinations",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /payout-destinations/{payout_destination_id}:
    parameters:
      - name: payout_destination_id
        in: path
        required: true
        description: The opaque payout-destination id (`ozav_pd_...`).
        schema: { type: string }
    get:
      operationId: getPayoutDestination
      tags: [ Payout Destinations ]
      summary: Retrieve a payout destination
      description: |
        Retrieves a single payout destination by its `ozav_pd_` id, including its
        `status`, `rail`, and rail-specific `details`.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ destinations:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The payout destination.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutDestination" }
              examples:
                pix_active:
                  summary: An active PIX destination
                  value:
                    id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: payout_destination
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    currency: BRL
                    rail: pix
                    status: active
                    account_holder: { name: Ana Ramos, type: individual }
                    details: { pix_key: ana@example.com }
                    created_at: 2026-07-12T18:05:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    patch:
      operationId: updatePayoutDestination
      tags: [ Payout Destinations ]
      summary: Update a payout destination
      description: |
        Updates a payout destination's mutable attributes (account holder,
        metadata). Only included fields are changed. Returns the updated
        `PayoutDestination`.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ destinations:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                account_holder: { $ref: "#/components/schemas/AccountHolder" }
                metadata: { $ref: "#/components/schemas/Metadata" }
            examples:
              holder:
                summary: Correct the account holder
                value:
                  account_holder:
                    name: Ana C. Ramos
                    type: individual
                  metadata:
                    reviewed: "true"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Updated payout destination.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutDestination" }
              examples:
                holder_corrected:
                  summary: Account holder corrected
                  value:
                    id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: payout_destination
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    currency: BRL
                    rail: pix
                    status: active
                    account_holder: { name: Ana C. Ramos, type: individual }
                    details: { pix_key: ana@example.com }
                    created_at: 2026-07-12T18:05:00Z
                    metadata: { reviewed: "true" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "account_holder": {
                  "name": "Ana C. Ramos",
                  "type": "individual"
                },
                "metadata": {
                  "reviewed": "true"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "account_holder": {
                  "name": "Ana C. Ramos",
                  "type": "individual"
                },
                "metadata": {
                  "reviewed": "true"
                }
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.patch(
                "https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "account_holder": {
                        "name": "Ana C. Ramos",
                        "type": "individual",
                    },
                    "metadata": {
                        "reviewed": "true",
                    },
                },
            )

            data = response.json()

  /payout-destinations/{payout_destination_id}/deactivate:
    post:
      operationId: deactivatePayoutDestination
      tags: [ Payout Destinations ]
      summary: Deactivate a payout destination
      description: |
        Deactivates a payout destination so it can no longer be used as a
        transfer destination; existing transfers are unaffected. Idempotent -
        deactivating an already-inactive destination is a no-op. Returns the
        `PayoutDestination` with `status: inactive`. Reverse it with the
        reactivate endpoint.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ destinations:write ]
      x-ozav-availability: live
      parameters:
        - name: payout_destination_id
          in: path
          required: true
          description: The opaque payout-destination id (`ozav_pd_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Deactivated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutDestination" }
              examples:
                inactive:
                  summary: The destination is now inactive
                  value:
                    id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: payout_destination
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    currency: BRL
                    rail: pix
                    status: inactive
                    account_holder: { name: Ana Ramos, type: individual }
                    details: { pix_key: ana@example.com }
                    created_at: 2026-07-12T18:05:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1/deactivate \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1/deactivate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1/deactivate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /payout-destinations/{payout_destination_id}/reactivate:
    post:
      operationId: reactivatePayoutDestination
      tags: [ Payout Destinations ]
      summary: Reactivate a payout destination
      description: |
        Reactivates a previously deactivated payout destination, making it usable
        again as a transfer destination. Idempotent. Returns the
        `PayoutDestination` with `status: active`.
      x-ozav-entitlement: liquidity
      x-ozav-scopes: [ destinations:write ]
      x-ozav-availability: live
      parameters:
        - name: payout_destination_id
          in: path
          required: true
          description: The opaque payout-destination id (`ozav_pd_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Reactivated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayoutDestination" }
              examples:
                active_again:
                  summary: The destination is active again
                  value:
                    id: ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: payout_destination
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    currency: BRL
                    rail: pix
                    status: active
                    account_holder: { name: Ana Ramos, type: individual }
                    details: { pix_key: ana@example.com }
                    created_at: 2026-07-12T18:05:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1/reactivate \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1/reactivate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/payout-destinations/ozav_pd_01J8Z9K2M3N4P5Q6R7S8T9U0V1/reactivate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /cards:
    post:
      operationId: createCard
      tags: [ Cards ]
      summary: Issue a virtual card
      description: |
        Issues an OZAV virtual card for a customer who is eligible for one.
        Eligibility is TWO conditions, not one: `verification.status` is
        `approved` AND the customer's account is active. Approval alone is not
        enough, and a customer who never opened an account never becomes
        eligible. Missing either one returns `422
        CUSTOMER_NOT_CARD_ELIGIBLE`. `Idempotency-Key` is required. Sandbox keys create a simulated active card end-to-end;
        live keys require the organization's live card program to be enabled.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CardCreateRequest" }
            examples:
              virtual:
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  type: virtual
                  funding_type: prepaid_balance
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "201":
          description: Card issued.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
              examples:
                card:
                  value:
                    id: ozav_card_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: card
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    type: virtual
                    status: active
                    funding_type: prepaid_balance
                    last4: "4242"
                    exp_month: 7
                    exp_year: 2029
                    brand_name: OZAV
                    created_at: 2026-07-12T18:00:00Z
                    activated_at: 2026-07-12T18:00:00Z
                    cancelled_at: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "type": "virtual",
                "funding_type": "prepaid_balance"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await fetch("https://developers.ozav.io/v2/cards",
            {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "type": "virtual",
                "funding_type": "prepaid_balance"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/cards",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "type": "virtual",
                    "funding_type": "prepaid_balance",
                },
            )
            data = response.json()
    get:
      operationId: listCards
      tags: [ Cards ]
      summary: List cards
      description: Lists cards with standard cursor pagination; filter by
        `customer_id` or `status`.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [ pending, active, frozen, cancelled, expired ] }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: Paginated cards.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Card" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await fetch("https://developers.ozav.io/v2/cards",
            {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/cards",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /cards/{card_id}:
    parameters:
      - name: card_id
        in: path
        required: true
        schema: { type: string }
        description: Opaque card id, prefixed `ozav_card_`.
    get:
      operationId: getCard
      tags: [ Cards ]
      summary: Retrieve a card
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/cards/{card_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/cards/{card_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

    patch:
      operationId: updateCard
      tags: [ Cards ]
      summary: Update a card's spending controls
      description: >-
        Replaces the card's spending controls without re-issuing the card. The
        PAN, expiry and card id are unchanged, so recurring subscriptions on the
        card keep working.


        **This replaces `spending_controls` in full; it does not merge.** Send
        the complete object: anything you omit is cleared. Merging is
        deliberately not offered because with nested lists (`spending_limits`,
        `blocked_categories`) it is ambiguous whether sending a list adds to the
        stored one or replaces it, and both readings are defensible. The request
        body is strict, so an unrecognised key returns `400` naming it rather
        than being silently dropped.


        Card state (`freeze`, `unfreeze`, `close`) is **not** settable here:
        those have their own routes, with state-transition guards and issuer
        ordering. A closed card returns `422`.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ spending_controls ]
              properties:
                spending_controls: { $ref: "#/components/schemas/CardSpendingControls" }
            examples:
              trocar_o_limite:
                summary: Change the monthly limit WITHOUT re-issuing the card
                value:
                  spending_controls:
                    spending_limits:
                      - amount: 500000
                        interval: monthly
              limpar_os_controles:
                summary: Remove every control - this REPLACES, it does not merge
                value:
                  spending_controls: {}

      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "200":
          description: The updated card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id} \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "spending_controls": {
                  "spending_limits": [
                    {
                      "amount": 500000,
                      "interval": "monthly"
                    }
                  ]
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/cards/{card_id}", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "spending_controls": {
                  "spending_limits": [
                    {
                      "amount": 500000,
                      "interval": "monthly"
                    }
                  ]
                }
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.patch(
                "https://developers.ozav.io/v2/cards/{card_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "spending_controls": {
                        "spending_limits": [
                            {
                                "amount": 500000,
                                "interval": "monthly",
                            },
                        ],
                    },
                },
            )
            data = response.json()

  /cards/{card_id}/freeze:
    post:
      operationId: freezeCard
      tags: [ Cards ]
      summary: Freeze a card
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "200":
          description: Updated card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id}/freeze \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/cards/{card_id}/freeze", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/cards/{card_id}/freeze",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /cards/{card_id}/unfreeze:
    post:
      operationId: unfreezeCard
      tags: [ Cards ]
      summary: Unfreeze a card
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "200":
          description: Updated card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id}/unfreeze \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/cards/{card_id}/unfreeze", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/cards/{card_id}/unfreeze",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /cards/{card_id}/close:
    post:
      operationId: closeCard
      tags: [ Cards ]
      summary: Close a card
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "200":
          description: Updated card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id}/close \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/cards/{card_id}/close", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/cards/{card_id}/close",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /cards/{card_id}/secure-display-session:
    post:
      operationId: createCardSecureDisplaySession
      tags: [ Cards ]
      summary: Create a secure card display session
      description: Returns a short-lived OZAV-hosted URL for sensitive card display.
        PAN, CVV, and PIN are never returned by the API.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                purpose: { type: string, enum: [ card_display ], default: card_display }
            examples:
              abrir_sessao:
                summary: Open a session so the cardholder can see the full PAN
                value:
                  purpose: card_display
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "201":
          description: Secure display session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardDisplaySession" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl
            https://developers.ozav.io/v2/cards/{card_id}/secure-display-session
            \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "purpose": "card_display"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/cards/{card_id}/secure-display-session", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "purpose": "card_display"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/cards/{card_id}/secure-display-session",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "purpose": "card_display",
                },
            )

            data = response.json()

  /cards/{card_id}/simulate/funding:
    post:
      operationId: simulateCardFunding
      tags: [ Cards ]
      summary: Load the prepaid balance (sandbox only)
      description: |
        Credits the card holder's prepaid balance so you can exercise authorisation, limits and
        declines. **Sandbox only** - a `live` key gets `404 SIMULATION_NOT_AVAILABLE`, because in
        production the balance arrives through settlement, never through a client call.

        Without this, a prepaid card is created `active` and declines every purchase with
        `Insufficient balance` forever, so authorisation, spending limits, declines and interchange
        could not be exercised at all.

        The credit lands on the same balance the authorisation debits, and the amount is yours to
        choose - so **declining for insufficient balance stays testable**: fund a small amount and
        simulate a purchase above it.

        The balance belongs to the **customer**, not to the card: funding here also funds that
        customer's other cards.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ amount_usd ]
              properties:
                amount_usd:
                  type: number
                  exclusiveMinimum: 0
                  description: How much to credit, in USD. Must be positive.
            example: { amount_usd: 50 }
            examples:
              creditar:
                summary: Credit the customer's balance so a card can be used
                value:
                  amount_usd: 250.00

      responses:
        "200":
          description: Balance credited.
          headers:
            X-Request-ID: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [ object, card_id, amount_usd, balance_after_usd ]
                properties:
                  object: { type: string, description: "Always `card_funding_simulation`." }
                  card_id: { type: string, description: "The `ozav_card_` id that was funded." }
                  amount_usd: { type: number, description: "The amount credited." }
                  balance_after_usd:
                    type: number
                    description: The holder's balance after the credit, read back from the ledger.
              example:
                object: card_funding_simulation
                card_id: ozav_card_01J8XY0000000000000000000
                amount_usd: 50
                balance_after_usd: 50
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl https://developers.ozav.io/v2/cards/{card_id}/simulate/funding
            \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "amount_usd": 250
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/cards/{card_id}/simulate/funding", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "amount_usd": 250
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/cards/{card_id}/simulate/funding",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "amount_usd": 250,
                },
            )

            data = response.json()
  /cards/{card_id}/simulate/purchase:
    post:
      operationId: simulateCardPurchase
      tags: [ Cards ]
      summary: Simulate a card purchase (sandbox only)
      description: >-
        Runs a purchase through the REAL authorisation decision and settles it
        if approved. A card purchase is initiated by a merchant, so unlike a
        transfer there is no client call to hang a simulator off; this route is
        that trigger. Available ONLY to test-environment API keys: a live key
        receives 404 SIMULATION_NOT_AVAILABLE. The decision is not fabricated,
        so a card with a configured spending limit is genuinely declined here,
        which is the only way to test that your limits work before they matter.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:write ]
      x-ozav-availability: live
      parameters:
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ amount_usd ]
              properties:
                amount_usd:
                  type: number
                  description: Purchase amount in USD. Must be positive.
                  example: 49.90
                merchant:
                  type: object
                  properties:
                    name: { type: [ string, "null" ], example: "Cafe Lisboa" }
                    category_code: { type: [ string, "null" ], description: "MCC.", example: "5812" }
                    country: { type: [ string, "null" ], example: "PT" }
                is_card_present: { type: boolean, default: false }
                is_refund:
                  type: boolean
                  default: false
                  description: Settles as a credit. A refund earns no interchange.
                presentment:
                  type: object
                  description: >-
                    Simulate an INTERNATIONAL purchase: what it cost in the
                    merchant's currency. Omit it to simulate a domestic one.


                    Send the amount in the currency's MINOR units, as the card
                    networks do - `5000` with `JPY` is five thousand yen, not
                    fifty. You do not send the number of decimal places: it
                    comes from the same ISO 4217 table the issuer adapter uses,
                    so sandbox and production agree on what `5000 JPY` is worth.


                    The settled transaction then carries `presentment` on `GET
                    /cards/{card_id}/transactions`, which is what lets you build
                    and test your international-transaction handling before a
                    real issuer is feeding the programme.
                  required: [ amount_minor, currency ]
                  properties:
                    amount_minor:
                      type: integer
                      minimum: 0
                      description: Minor units of `currency`. Non-negative integer.
                      example: 5000
                    currency:
                      type: string
                      description: ISO 4217 code of the merchant's currency.
                      example: JPY
            examples:
              internacional:
                summary: An INTERNATIONAL purchase (JPY merchant, USD card)
                value:
                  amount_usd: 33.12
                  merchant:
                    name: Ramen Ichiban
                    category_code: "5812"
                    country: JP
                  presentment:
                    amount_minor: 5000
                    currency: JPY
              domestica:
                summary: A domestic purchase - omit `presentment`
                value:
                  amount_usd: 49.90
                  merchant:
                    name: Cafe Lisboa
                    category_code: "5812"
                    country: PT
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "200":
          description: >-
            The decision, and the settled transaction reference when approved. A
            declined purchase settles nothing and returns a null
            transaction_ref.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, example: card_purchase_simulation }
                  card_id: { type: string }
                  approved: { type: boolean }
                  decline_reason: { type: [ string, "null" ] }
                  authorization_ref: { type: string, example: "sim_auth_9k2m..." }
                  transaction_ref: { type: [ string, "null" ], example: "sim_txn_4b8x..." }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl https://developers.ozav.io/v2/cards/{card_id}/simulate/purchase
            \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "amount_usd": 33.12,
                "merchant": {
                  "name": "Ramen Ichiban",
                  "category_code": "5812",
                  "country": "JP"
                },
                "presentment": {
                  "amount_minor": 5000,
                  "currency": "JPY"
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/cards/{card_id}/simulate/purchase", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "amount_usd": 33.12,
                "merchant": {
                  "name": "Ramen Ichiban",
                  "category_code": "5812",
                  "country": "JP"
                },
                "presentment": {
                  "amount_minor": 5000,
                  "currency": "JPY"
                }
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/cards/{card_id}/simulate/purchase",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "amount_usd": 33.12,
                    "merchant": {
                        "name": "Ramen Ichiban",
                        "category_code": "5812",
                        "country": "JP",
                    },
                    "presentment": {
                        "amount_minor": 5000,
                        "currency": "JPY",
                    },
                },
            )

            data = response.json()
  /cards/{card_id}/transactions:
    get:
      operationId: listCardTransactions
      tags: [ Cards ]
      summary: List card transactions
      description: >-
        Settled card movement - money that actually cleared. The sandbox
        response is an empty list until simulated card activity is added.


        **This is how you reconcile card spend: there is no per-transaction
        webhook.** The card events (`card.created`, `card.updated`) are
        lifecycle only; nothing fires when a card is used. Poll this route (and
        `/authorizations` for holds that have not settled yet).


        To tell an international purchase from a domestic one, compare
        `presentment.currency` with `currency` - not `merchant.country`, which
        answers a different question.

      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:read ]
      x-ozav-availability: live
      parameters:
        # O servidor SEMPRE aceitou os dois (`parsePageParams` em v2-cards/routes.ts),
        # e a resposta e um `Page`, entao ela ja devolve `has_more` e `next_cursor`. Sem declarar
        # aqui, um cliente gerado do spec recebe o cursor e NAO TEM ONDE devolve-lo.
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Card transactions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardActivityPage" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id}/transactions \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/cards/{card_id}/transactions",
            {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/cards/{card_id}/transactions",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /cards/{card_id}/authorizations:
    get:
      operationId: listCardAuthorizations
      tags: [ Cards ]
      summary: List card authorizations
      description: Lists pending card authorizations. The sandbox response is an empty
        list until simulated authorization activity is added.
      x-ozav-entitlement: cards
      x-ozav-scopes: [ cards:read ]
      x-ozav-availability: live
      parameters:
        # O servidor SEMPRE aceitou os dois (`parsePageParams` em v2-cards/routes.ts),
        # e a resposta e um `Page`, entao ela ja devolve `has_more` e `next_cursor`. Sem declarar
        # aqui, um cliente gerado do spec recebe o cursor e NAO TEM ONDE devolve-lo.
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: card_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Card authorizations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardActivityPage" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/cards/{card_id}/authorizations \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/cards/{card_id}/authorizations", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/cards/{card_id}/authorizations",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /wallets:
    post:
      operationId: registerSelfCustodyWallet
      tags: [ Wallets ]
      summary: Register a self-custody wallet the customer already controls
      description: |
        Records the wallet address your customer already controls, so OZAV can
        show its balances and build unsigned transactions for it.

        **This does not create a wallet.** In self-custody there is nothing to
        create: your customer holds the key and the address already exists. OZAV
        records it. That is also why `custody_model` accepts only `self_custody`
        here - a `managed` wallet is provisioned as part of the customer, never
        requested through this route.

        Until now the only way to register an address was the `self_custody`
        block on `POST /customers`, so a customer that already existed had no
        path at all.

        **Recording is not proof of control.** OZAV checks that the address has
        the SHAPE of an address on the declared network, which catches the real
        and frequent mistake of pasting an address from another chain. Proving
        that your customer holds the key would require a signed challenge, which
        this route does not do.

        One address per customer: registering again REPLACES the previous one,
        and the response says so in `replaced_previous_address`. It is never
        silent - a customer who believes they still have two wallets could send
        funds to the old one.
      x-ozav-entitlement: self_custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ customer_id, custody_model, chain, address ]
              properties:
                customer_id:
                  type: string
                  description: The customer this wallet belongs to (`ozav_cus_...`).
                custody_model:
                  type: string
                  const: self_custody
                  description: Only `self_custody`. A managed wallet is not requested here.
                chain:
                  type: string
                  description: >-
                    The network your customer's address lives on.


                    **`tron` and `stellar` are refused with `422
                    CHAIN_HAS_NO_SELF_CUSTODY_CAPABILITY`.** They stay in this
                    enum because they are still valid networks elsewhere in the
                    API (managed custody serves both), but no endpoint can move
                    a *self-custody* wallet on them today: `POST /transfers`
                    only accepts a managed source, and `POST
                    /wallets/{wallet_id}/transaction-intents` builds `{to, data,
                    value, chain_id}`, which is an EVM transaction shape.
                    Registering one would hand you a wallet with no way out, so
                    the refusal is deliberate - use a managed-custody wallet on
                    those networks instead.


                    `solana` **is** accepted even though it has no
                    transaction-intents support: a self-custody Solana wallet is
                    served by the yield line, so it is not a dead end.
                  enum:
                    [
                      ethereum,
                      base,
                      polygon,
                      arbitrum,
                      optimism,
                      avalanche_c_chain,
                      solana,
                      tron,
                      stellar
                    ]
                address:
                  type: string
                  description: >-
                    The address your customer controls. Sent back exactly as
                    supplied - OZAV does not normalise case, because on EVM the
                    mixed case IS the checksum that catches a mistyped digit.
            examples:
              evm:
                summary: Register a Base address
                value:
                  customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                  custody_model: self_custody
                  chain: base
                  address: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
        "422":
          description: >
            Well-formed but not recordable. `ADDRESS_NOT_VALID_FOR_CHAIN` - the
            address does not have the shape of an address on the declared
            network, which is almost always an address pasted from another
            chain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "201":
          description: The recorded self-custody wallet.
          content:
            application/json:
              schema:
                type: object
                required:
                  [
                    id,
                    object,
                    customer_id,
                    custody_model,
                    network,
                    address,
                    replaced_previous_address
                  ]
                properties:
                  id:
                    type: string
                    description: >-
                      Opaque wallet identifier, prefixed `ozav_wal_`. This is
                      the id to pass to `GET /wallets/{wallet_id}/balances` and
                      every other wallet route. It is the SAME id `GET /wallets`
                      returns for this wallet, so you do not need to list back
                      after creating. Passing a `ozav_cus_` id to a wallet route
                      answers `404 WALLET_NOT_FOUND`.
                  object: { type: string, const: wallet }
                  customer_id: { type: string }
                  custody_model: { type: string, const: self_custody }
                  network: { type: string }
                  address: { type: string }
                  replaced_previous_address:
                    type: boolean
                    description: >-
                      `true` when a DIFFERENT address was on file before. Stop
                      using the old one. Re-registering the same address is
                      idempotent and reports `false`.
              examples:
                recorded:
                  summary: First address recorded for this customer
                  value:
                    id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0W2
                    object: wallet
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    custody_model: self_custody
                    network: base
                    address: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                    replaced_previous_address: false
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "custody_model": "self_custody",
                "chain": "base",
                "address": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/wallets", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                "custody_model": "self_custody",
                "chain": "base",
                "address": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/wallets",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "customer_id": "ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                    "custody_model": "self_custody",
                    "chain": "base",
                    "address": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                },
            )
            data = response.json()
    get:
      operationId: listWallets
      tags: [ Wallets ]
      summary: List wallets
      description: |
        Lists wallets with the standard cursor pagination; filter with the
        `customer_id` query parameter. Each wallet reports its `custody_model`.
        Reads are tenant-scoped. Returns a `Page` of `Wallet`.
        Sandbox verification simulation creates a simulated managed wallet
        projection for approved customers when no wallet or self-custody address
        already exists. Live managed wallet provisioning, on-chain addresses,
        and managed sends remain gated. Use `GET /accounts?customer_id=...`
        for customer BRL/Pix account instructions.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - name: customer_id
          in: query
          description: Filter to a single customer by its opaque `ozav_cus_` id.
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Paginated wallets.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Wallet" }
              examples:
                page:
                  summary: One page of wallets
                  value:
                    data:
                      - id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: wallet
                        customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        custody_model: managed
                        status: active
                        created_at: 2026-07-12T18:00:00Z
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/wallets", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/wallets",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /wallets/{wallet_id}:
    get:
      operationId: getWallet
      tags: [ Wallets ]
      summary: Retrieve a wallet
      description: |
        Retrieves a single wallet by its `ozav_wal_` id, including its
        `custody_model` and `status`. A wallet outside your organization returns
        `404`, never a leak.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The wallet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Wallet" }
              examples:
                managed:
                  summary: A managed wallet
                  value:
                    id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: wallet
                    customer_id: ozav_cus_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    custody_model: managed
                    status: active
                    created_at: 2026-07-12T18:00:00Z
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /wallets/{wallet_id}/deposit-addresses:
    get:
      operationId: listWalletDepositAddresses
      tags: [ Wallets ]
      summary: List the deposit addresses of a wallet
      description: >
        The on-chain addresses to send funds to so they credit this wallet.
        Tenant-scoped: a `wallet_id` outside the caller's tenant answers 404 and
        never leaks existence.


        Read `memo_required` before you build a deposit instruction. Some
        networks attribute a deposit by MEMO rather than by address: on those,
        every customer shares one account and the memo is the only thing that
        routes the funds to the right one. A deposit sent without it, or with
        the wrong one, cannot be attributed. Where `memo_required` is true the
        `memo` field is always present; where it is false, `memo` is null and
        must not be sent.


        A wallet in self-custody returns an empty `data` array: the funds are
        the end user's, on their own address, and OZAV has no address to offer.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: One deposit address per supported network.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DepositAddress" }
              examples:
                deposit_addresses:
                  summary: An address-per-user network and a memo-based one
                  value:
                    data:
                      - object: deposit_address
                        network: ethereum
                        address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
                        memo: null
                        memo_required: false
                        created_at: "2026-08-12T10:00:00Z"
                      - object: deposit_address
                        network: stellar
                        address: "GBTUMXPEAPKTFIZAI47GKGUWMHWUONVX6PXGBPLSCC6WM3MYDPHQ3VSL"
                        memo: "mA1b2C3d4E5f6G7h8I9j0K1l2M3n"
                        memo_required: true
                        created_at: "2026-08-12T10:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/deposit-addresses \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/deposit-addresses", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/deposit-addresses",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/balances:
    get:
      operationId: listWalletBalances
      tags: [ Wallets ]
      summary: List balances of a wallet
      description: >
        This read is wired and tenant-scoped, and returns an empty `data` array
        until custody accounts exist - no balances are projected yet.
        Tenant-scoped read. The handler MUST verify the wallet belongs to the
        authenticated client and return 404 (never leak existence) for a
        `wallet_id` outside the caller's tenant - a valid `wallets:read` scope
        alone does NOT grant cross-tenant balance reads. This ownership check is
        the load-bearing control and MUST live in the auth kernel's request
        scope, not be left as a per-handler implementation detail. NOTE
        (entitlement precision): the persona matrix grants balances to neobank +
        liquidity (settlement wallet) + custody, but NOT compliance - which the
        single-value `x-ozav-entitlement` cannot express (`custody` would drop
        liquidity; `any` admits compliance). Left `any` because the
        `wallets:read` scope grant plus the ownership check already bound
        access; resolve to a proper entitlement SET once the auth model supports
        one.
      x-ozav-entitlement: any
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Balances by currency (and network where applicable).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Balance" }
              examples:
                balances:
                  summary: Balances by currency and network
                  value:
                    data:
                      - amount: "1500.000000"
                        currency: USDC
                        network: base
                      - amount: "250.500000"
                        currency: USDT
                        network: tron
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/balances \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/balances", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/balances",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /wallets/{wallet_id}/onchain-balances:
    get:
      operationId: listWalletOnchainBalances
      tags: [ Wallets ]
      summary: List on-chain balances of a wallet, per network
      description: >
        What is AT THE ADDRESS, on each network, right now. This is NOT the
        ledger balance returned by `/balances`, and the two do not add up to the
        same number - read both descriptions before reconciling. `/balances`
        reports the AVAILABLE ledger balance (posted credits minus posted debits
        minus pending debits) per CURRENCY, with no network, because the custody
        ledger account is per asset and shared across chains. This route reports
        the raw on-chain balance of each deposit address: it does not subtract
        in-flight outbound, does not wait for finality, and DOES include a
        deposit that arrived but has not been credited yet. Use it to answer
        "how much can I move out on network X" and to see uncredited inbound;
        use `/balances` to reconcile against OZAV's books. TWO RULES DECIDE
        WHICH NETWORKS APPEAR, and both omit rather than report a zero. FIRST,
        ONLY THE NETWORKS OFFERED TO YOUR ORGANIZATION APPEAR. That set is the
        proven-withdrawal ceiling intersected with your own configuration, and
        it is the SAME set `/deposit-addresses` serves, so the two routes always
        agree on which networks exist for you. `x-ozav-network-availability` on
        the `Network` schema is the machine-readable source: `available` means
        every client, `opt_in` means per-client on request, `deposit_paused`
        means nobody. A network outside your set is absent from this response
        even when an address was provisioned on it in the past. Nothing is
        hidden by that: a deposit arriving at such an address is still watched
        and credited, so it appears in `/balances`, the ledger route. What is
        absent here is the raw on-chain view of a network you are not offered,
        never the balance itself. SECOND, NETWORKS WITH A SHARED DEPOSIT ADDRESS
        ARE OMITTED. On networks that attribute by memo, every customer shares
        one account, so the on-chain balance of that address belongs to all
        customers at once and is not attributable to the caller - reporting it
        would show other customers' funds. `/deposit-addresses` omits the same
        networks, for a related but distinct reason (an address without its memo
        is an unusable instruction). `amount` is `null` when the balance could
        NOT BE READ (an unreachable node, a network with no configured endpoint)
        - never `0`. "Unreadable" and "empty" call for opposite actions, and a
        fabricated zero is indistinguishable from an empty wallet. When `amount`
        is `null`, `unreadable_reason` says what happened, and the other
        networks in the same response are unaffected. Tenant-scoped read,
        identical control to `/balances`: the handler verifies the wallet
        belongs to the authenticated client and returns 404 (never leak
        existence) for a `wallet_id` outside the caller's tenant. A valid
        `wallets:read` scope alone does NOT grant cross-tenant reads. For a
        self-custody wallet OZAV holds no addresses at all, so the response is
        an empty `data` array.
      x-ozav-entitlement: any
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: On-chain balances, one entry per (network, currency).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/OnchainBalance" }
              examples:
                onchain_balances:
                  summary: Two networks read, one unreadable
                  value:
                    data:
                      - object: onchain_balance
                        network: tron
                        currency: USDT
                        address: TLkDwwyqdTXjaUnCKTTYJzxtKMh8R492EF
                        amount: "5.750742"
                      - object: onchain_balance
                        network: ethereum
                        currency: USDT
                        address: "0x761c6FdE080a2A386834527a0Eb4eDbDc7f6B378"
                        amount: "2.2"
                      - object: onchain_balance
                        network: polygon
                        currency: USDC
                        address: "0x761c6FdE080a2A386834527a0Eb4eDbDc7f6B378"
                        amount: null
                        unreadable_reason: "leitura de saldo passou de 4000ms"
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/onchain-balances \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/onchain-balances", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/onchain-balances",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /wallets/{wallet_id}/transaction-intents:
    post:
      operationId: createTransactionIntent
      tags: [ Transaction Intents ]
      summary: Build an unsigned transaction for a self-custody wallet
      description: |
        For `self_custody` wallets only. The server builds the calldata and
        returns an **unsigned** transaction; signing and broadcasting are done
        by the client (or their end-user's wallet). **OZAV never holds keys for
        self-custody wallets, and never signs this transaction.**

        The token contract comes from OZAV's verified registry, and the amount is
        converted to the token's smallest unit without floating point. Anything
        that cannot be built exactly is REFUSED (`422`) rather than approximated:
        a network whose transaction format is not `{to, data, value, chain_id}`,
        an asset not verified on that network, or an amount smaller than one unit
        of the token (which would build a transaction that moves nothing).

        Requires a wallet whose `custody_model` is `self_custody`; a managed
        wallet answers `422 WALLET_NOT_SELF_CUSTODY`, because there is no client
        key to sign with.
      x-ozav-entitlement: self_custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ intent ]
              properties:
                intent:
                  type: string
                  description: |
                    The transaction to build - a SEND from this wallet:
                    - `withdraw` - send funds out to an external destination.
                    - `transfer` - move funds to another address.
                    - `deposit` - rejected: inbound deposits are signed by the external payer, not built here.
                  enum: [ deposit, withdraw, transfer ]
                currency: { $ref: "#/components/schemas/Currency" }
                amount: { $ref: "#/components/schemas/DecimalAmount" }
                destination:
                  {
                    type: string,
                    description: "The recipient address the send goes to (must be valid for the
                      wallet's chain)."
                  }
            examples:
              transfer:
                summary: Build an unsigned USDC transfer
                value:
                  intent: transfer
                  currency: USDC
                  amount: "250.000000"
                  destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Unsigned transaction payload.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransactionIntent" }
              examples:
                unsigned:
                  summary: An unsigned USDC transfer for the client to sign
                  value:
                    id: ozav_txi_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: transaction_intent
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    intent: transfer
                    unsigned_transaction:
                      to: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                      data: "0xa9059cbb0000000000000000000000001d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7\
                        e800000000000000000000000000000000000000000000000000000\
                        0000ee6b280"
                      value: "0"
                      chain_id: 8453
                    expires_at: 2026-07-12T18:05:00Z
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transaction-intents \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "intent": "transfer",
                "currency": "USDC",
                "amount": "250.000000",
                "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transaction-intents", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "intent": "transfer",
                "currency": "USDC",
                "amount": "250.000000",
                "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transaction-intents",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "intent": "transfer",
                    "currency": "USDC",
                    "amount": "250.000000",
                    "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                },
            )

            data = response.json()

  /approvals:
    get:
      operationId: listApprovals
      tags: [ Approvals ]
      summary: List approvals
      description: |
        The decisions your approver keys owe, newest first. Filter with
        `?status=pending` to build an approval inbox. A `pending` approval past
        `expires_at` reads as `expired` here, and the held operation is released
        on that first read.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ approvals:read ]
      x-ozav-availability: live
      parameters:
        - name: status
          in: query
          required: false
          schema:
            {
              type: string,
              enum: [ pending, approved, rejected, expired, executed, failed ]
            }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "200":
          description: A page of approvals (without per-key decisions; read one for those).
          content:
            application/json:
              schema:
                type: object
                required: [ data, has_more, next_cursor ]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Approval" }
                  has_more: { type: boolean }
                  next_cursor:
                    oneOf: [ { type: string }, { type: "null" } ]
              examples:
                inbox:
                  summary: One send waiting for a second approval
                  value:
                    data:
                      - id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                        object: approval
                        kind: wallet_transfer
                        status: pending
                        resource: { type: wallet_transfer, id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2 }
                        wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        required_approvals: 2
                        approvals_received: 0
                        summary:
                          {
                            amount: "2500.000000",
                            currency: USDC,
                            destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                            network: polygon
                          }
                        decisions: []
                        requested_by_api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9
                        failure_reason: null
                        expires_at: "2026-09-12T12:00:00Z"
                        created_at: "2026-09-09T12:00:00Z"
                        decided_at: null
                        executed_at: null
                    has_more: false
                    next_cursor: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/approvals \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/approvals", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/approvals",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /approvals/{approval_id}:
    get:
      operationId: getApproval
      tags: [ Approvals ]
      summary: Retrieve an approval
      description: One approval with every decision recorded so far. Tenant- and
        environment-scoped; an id from another tenant or environment answers
        404.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ approvals:read ]
      x-ozav-availability: live
      parameters:
        - name: approval_id
          in: path
          required: true
          description: The opaque approval id (`ozav_apr_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The approval.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Approval" }
              examples:
                one_of_two:
                  summary: One approval received, one still owed
                  value:
                    id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                    object: approval
                    kind: wallet_transfer
                    status: pending
                    resource: { type: wallet_transfer, id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2 }
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    required_approvals: 2
                    approvals_received: 1
                    summary:
                      {
                        amount: "2500.000000",
                        currency: USDC,
                        destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                        network: polygon
                      }
                    decisions:
                      - api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0VA
                        decision: approve
                        reason: null
                        decided_at: "2026-09-09T12:10:00Z"
                    requested_by_api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9
                    failure_reason: null
                    expires_at: "2026-09-12T12:00:00Z"
                    created_at: "2026-09-09T12:00:00Z"
                    decided_at: null
                    executed_at: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/approvals/{approval_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/approvals/{approval_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/approvals/{approval_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /approvals/{approval_id}/approve:
    post:
      operationId: approveApproval
      tags: [ Approvals ]
      summary: Approve
      description: |
        Record this key's approval. Rules, all enforced: the key must hold
        `approvals:write`; it must not be the key that requested the operation
        (`403 APPROVER_IS_REQUESTER`); it decides once (`409
        APPROVER_ALREADY_DECIDED`); a decided or expired approval does not move
        (`409 APPROVAL_NOT_PENDING` / `APPROVAL_EXPIRED`).

        When this approval is the last one required, **the held operation
        executes in this same request**: a `POST /wallets/{id}/transfers` send is
        signed and broadcast, a `POST /transfers` send moves to `converting`, a
        quorum loosening is applied. The response then carries `status:
        executed` (or `failed`, with `failure_reason`, when execution was refused
        - nothing moved) and a `result` object with the executed resource.
        Mint one API key per approver; a quorum of one key is not a quorum.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ approvals:write ]
      x-ozav-availability: live
      parameters:
        - name: approval_id
          in: path
          required: true
          description: The opaque approval id (`ozav_apr_...`).
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApprovalDecisionRequest" }
            examples:
              plain:
                summary: Approve without a note
                value: {}
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`APPROVER_ALREADY_DECIDED`, `APPROVAL_NOT_PENDING` or
            `APPROVAL_EXPIRED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "200":
          description: The approval after this decision; with `result` when it executed.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Approval"
                  - type: object
                    properties:
                      result:
                        type: object
                        additionalProperties: true
                        description: Present when this decision executed the operation - the resulting
                          `wallet_transfer`, `transfer` or
                          `wallet_policy_change`.
              examples:
                executed:
                  summary: The second approval executed the send
                  value:
                    id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                    object: approval
                    kind: wallet_transfer
                    status: executed
                    resource: { type: wallet_transfer, id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2 }
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    required_approvals: 2
                    approvals_received: 2
                    summary:
                      {
                        amount: "2500.000000",
                        currency: USDC,
                        destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                        network: polygon
                      }
                    decisions:
                      - {
                          api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0VA,
                          decision: approve,
                          reason: null,
                          decided_at: "2026-09-09T12:10:00Z"
                        }
                      - {
                          api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0VB,
                          decision: approve,
                          reason: null,
                          decided_at: "2026-09-09T12:20:00Z"
                        }
                    requested_by_api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9
                    failure_reason: null
                    expires_at: "2026-09-12T12:00:00Z"
                    created_at: "2026-09-09T12:00:00Z"
                    decided_at: "2026-09-09T12:20:00Z"
                    executed_at: "2026-09-09T12:20:00Z"
                    result:
                      id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2
                      object: wallet_transfer
                      wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                      status: completed
                      amount: "2500.000000"
                      currency: USDC
                      destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                      transaction_hash: "0x6c984fe16f15aa9865e7ca950c5038542e5789c8bd6dfc2fd95f649514\
                        a88b47"
                      settled_at: "2026-09-09T12:20:03Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/approvals/{approval_id}/approve \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{}'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/approvals/{approval_id}/approve", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({}),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/approvals/{approval_id}/approve",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={},
            )
            data = response.json()
  /approvals/{approval_id}/reject:
    post:
      operationId: rejectApproval
      tags: [ Approvals ]
      summary: Reject
      description: |
        Record this key's rejection. One rejection is final: the approval becomes
        `rejected`, the held send is released (a `POST /transfers` send reads
        `cancelled`; a `POST /wallets/{id}/transfers` send reads `rejected`),
        `transfer.rejected` is emitted with your `reason`, and nothing moves.
        Same key rules as approve.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ approvals:write ]
      x-ozav-availability: live
      parameters:
        - name: approval_id
          in: path
          required: true
          description: The opaque approval id (`ozav_apr_...`).
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApprovalDecisionRequest" }
            examples:
              with_reason:
                summary: Reject, saying why
                value: { reason: "Destination not in the vendor list" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`APPROVER_ALREADY_DECIDED`, `APPROVAL_NOT_PENDING` or
            `APPROVAL_EXPIRED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "200":
          description: The approval, now `rejected`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Approval" }
              examples:
                rejected:
                  summary: Rejected by one approver
                  value:
                    id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                    object: approval
                    kind: wallet_transfer
                    status: rejected
                    resource: { type: wallet_transfer, id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2 }
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    required_approvals: 2
                    approvals_received: 0
                    summary:
                      {
                        amount: "2500.000000",
                        currency: USDC,
                        destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                        network: polygon
                      }
                    decisions:
                      - {
                          api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0VA,
                          decision: reject,
                          reason: "Destination not in the vendor list",
                          decided_at: "2026-09-09T12:10:00Z"
                        }
                    requested_by_api_key_id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9
                    failure_reason: null
                    expires_at: "2026-09-12T12:00:00Z"
                    created_at: "2026-09-09T12:00:00Z"
                    decided_at: "2026-09-09T12:10:00Z"
                    executed_at: null
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/approvals/{approval_id}/reject \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "reason": "Destination not in the vendor list"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/approvals/{approval_id}/reject", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "reason": "Destination not in the vendor list"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/approvals/{approval_id}/reject",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "reason": "Destination not in the vendor list",
                },
            )
            data = response.json()
  /wallets/{wallet_id}/addresses:
    get:
      operationId: listWalletAddresses
      tags: [ Wallets ]
      summary: List the address book of a wallet
      description: |
        The destinations of this managed wallet with your labels: what is
        `allowed` (leaves without human review), what you proposed and is still
        `pending_review`, and what was `rejected`. This is the same list as
        `destinations.allowed` on the policy, plus the pending proposals - one
        source, so what you see is what the custody plane applies.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The address book.
          content:
            application/json:
              schema:
                type: object
                required: [ data ]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WalletAddress" }
              examples:
                book:
                  summary: One allowed destination and one awaiting review
                  value:
                    data:
                      - object: wallet_address
                        network: ethereum
                        address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
                        label: cold vault
                        status: allowed
                        added_at: "2026-08-01T00:00:00Z"
                        requested_at: "2026-08-01T00:00:00Z"
                      - object: wallet_address
                        network: solana
                        address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"
                        label: treasury exchange
                        status: pending_review
                        added_at: null
                        requested_at: "2026-09-09T12:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    post:
      operationId: createWalletAddress
      tags: [ Wallets ]
      summary: Add a destination to the address book
      description: |
        Propose a destination for the allowlist, with a label. **Nothing changes
        on this call**: the entry answers `202 pending_review` and joins the same
        review queue as `PATCH /wallets/{id}/policy`; when OZAV approves it,
        `wallet_policy.updated` fires and the entry reads `allowed`. A destination
        already allowed answers `200` with the existing entry and proposes nothing.
        Addresses are validated for their network; EVM addresses are stored
        lower-cased. `Idempotency-Key` is required. Read-only in the sandbox
        (`422 POLICY_SANDBOX_READ_ONLY`).
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          description: >
            Required idempotency key (16-256 characters of `[A-Za-z0-9_-]`) -
            the request enters a review queue and must be safely retryable.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WalletAddressCreateRequest" }
            examples:
              labelled:
                summary: A labelled payout address on Ethereum
                value:
                  network: ethereum
                  address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
                  label: Binance treasury
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`IDEMPOTENCY_KEY_REUSED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: "`DESTINATION_ADDRESS_INVALID`, `NETWORK_NOT_SUPPORTED`,
            `WALLET_NOT_MANAGED` or `POLICY_SANDBOX_READ_ONLY`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "200":
          description: Already allowed; the existing entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletAddress" }
        "202":
          description: Queued for review.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletAddress" }
              examples:
                queued:
                  summary: Awaiting review
                  value:
                    object: wallet_address
                    network: ethereum
                    address: "0x742d35cc6634c0532925a3b844bc454e4438f44e"
                    label: Binance treasury
                    status: pending_review
                    added_at: null
                    requested_at: "2026-09-09T12:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "network": "ethereum",
                "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
                "label": "Binance treasury"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "network": "ethereum",
                "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
                "label": "Binance treasury"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "network": "ethereum",
                    "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
                    "label": "Binance treasury",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/addresses/{network}/{address}:
    delete:
      operationId: deleteWalletAddress
      tags: [ Wallets ]
      summary: Remove a destination from the address book
      description: |
        Take an `allowed` destination out of the allowlist **now** (`204`). Removing
        only reduces risk, so no one has to approve it; from then on a send to that
        address above `limits.auto_approve_below_usd` goes to review like any
        unlisted destination. `wallet_policy.updated` fires with
        `kind: destination.remove`. A `pending_review` proposal cannot be withdrawn
        here - OZAV decides it; a destination not in the allowlist answers `404`.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: network
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Network" }
        - name: address
          in: path
          required: true
          description: The destination address (URL-encoded). EVM addresses match
            case-insensitively.
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: "`NETWORK_NOT_SUPPORTED` or `WALLET_NOT_MANAGED`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "204":
          description: Removed.
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses/{network}/{address} \
              -X DELETE \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses/{network}/{address}", {
              method: "DELETE",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.delete(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/addresses/{network}/{address}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/freeze:
    get:
      operationId: getWalletFreeze
      tags: [ Wallets ]
      summary: Read whether a wallet is frozen
      description: |
        Whether sends from this managed wallet are paused, by whom, and since when.
        A `self_custody` wallet answers `422 WALLET_NOT_MANAGED`.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The freeze state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletFreeze" }
              examples:
                frozen_by_you:
                  summary: Frozen by your organization
                  value:
                    object: wallet_freeze
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    frozen: true
                    frozen_by: customer
                    reason: "customer:ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9: key possibly leaked"
                    since: "2026-09-09T12:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/freeze \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/freeze", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/freeze",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    post:
      operationId: freezeWallet
      tags: [ Wallets ]
      summary: Freeze a wallet
      description: |
        Pause every send from this managed wallet **now**. From this response on,
        `POST /wallets/{id}/transfers` and `POST /transfers` from it answer
        `409 WALLET_FROZEN` before anything is signed. Deposits keep arriving.
        Any key with `wallets:write` can freeze: freezing only reduces risk, so no
        one has to approve it. Freezing an already frozen wallet answers `200` with
        the existing freeze and emits nothing; the first freeze answers `201` and
        emits `wallet.frozen`. Unfreezing is the guarded direction - see below.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WalletFreezeRequest" }
            examples:
              suspected_leak:
                summary: Freeze with a note
                value: { reason: "key possibly leaked" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Already frozen; the existing freeze.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletFreeze" }
        "201":
          description: Frozen now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletFreeze" }
              examples:
                frozen:
                  summary: Frozen now
                  value:
                    object: wallet_freeze
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    frozen: true
                    frozen_by: customer
                    reason: "customer:ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9: key possibly leaked"
                    since: "2026-09-09T12:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/freeze \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "reason": "key possibly leaked"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/freeze", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "reason": "key possibly leaked"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/freeze",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "reason": "key possibly leaked",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/unfreeze:
    post:
      operationId: unfreezeWallet
      tags: [ Wallets ]
      summary: Unfreeze a wallet
      description: |
        Lift the freeze **your organization** placed. Without an approval quorum on
        the wallet it lifts at once (`200`, `wallet.unfrozen`). With
        `approvals.mode: customer_quorum` on the wallet's policy, unfreezing is a
        loosening: it answers `202` with `unfreeze_approval_id`, and the wallet stays
        frozen until `required_approvals` approver keys approve that approval
        (`409 APPROVAL_PENDING` while one already waits). A freeze placed by OZAV
        (`frozen_by: ozav`) is not yours to lift: the call answers `200` with the
        state unchanged - contact support.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WalletFreezeRequest" }
            examples:
              false_alarm:
                summary: Lift with a note
                value: { reason: "false alarm" }
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`APPROVAL_PENDING` - an unfreeze already awaits your quorum."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "200":
          description: Lifted (or nothing of yours to lift).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletFreeze" }
              examples:
                lifted:
                  summary: Lifted now
                  value:
                    object: wallet_freeze
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    frozen: false
                    frozen_by: null
                    reason: null
                    since: null
        "202":
          description: Your quorum must approve the unfreeze; the wallet stays frozen
            meanwhile.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletFreeze" }
              examples:
                awaiting_quorum:
                  summary: Waiting for two approver keys
                  value:
                    object: wallet_freeze
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    frozen: true
                    frozen_by: customer
                    reason: "customer:ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V9: key possibly leaked"
                    since: "2026-09-09T12:00:00Z"
                    unfreeze_approval_id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V4
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/unfreeze \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "reason": "false alarm"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/unfreeze", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "reason": "false alarm"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/unfreeze",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "reason": "false alarm",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/policy:
    get:
      operationId: getWalletPolicy
      tags: [ Wallets ]
      summary: Read the policy of a managed wallet
      description: |
        What OZAV applies before signing a send from this `managed` wallet, as one
        object: the destinations that leave without human review, the limits in
        force and where they come from, who reviews what falls outside them, and
        the changes you asked for that are still being decided.

        **Read `limits.source` before you read the numbers.** `wallet` means a
        signed per-wallet record is in force; `organization` means no per-wallet
        record exists and your organization's default limits apply;
        `unconfigured` means neither exists, and **every** send from this wallet
        goes to human review until one does.

        A destination outside `destinations.allowed` is not refused: a send to it
        above `limits.auto_approve_below_usd` is parked for review (`needs_review`)
        and resumes when a reviewer accepts it. Below that amount a clean send to
        an unlisted destination leaves on its own. Tenant-scoped: a `wallet_id`
        outside the caller's tenant answers 404 and never leaks existence. A
        `self_custody` wallet has no policy here (its owner holds the key) and
        answers `422 WALLET_NOT_MANAGED`.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The policy in force, and the changes still being decided.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletPolicy" }
              examples:
                policy:
                  summary: A wallet with a signed limit record and one destination awaiting review
                  value:
                    object: wallet_policy
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    destinations:
                      mode: allowlist
                      allowed:
                        - network: ethereum
                          address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
                          added_at: "2026-08-01T00:00:00Z"
                        - network: tron
                          address: "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8"
                          added_at: "2026-08-02T00:00:00Z"
                    limits:
                      per_transaction_usd: 50000
                      daily_usd: 200000
                      auto_approve_below_usd: 1000
                      source: wallet
                      valid_until: "2027-02-01T00:00:00Z"
                    approvals:
                      mode: ozav_review
                    pending_changes:
                      - id: 6f1d2c3b-4a5e-4f60-9a1b-2c3d4e5f6a7b
                        kind: destination.add
                        status: pending_review
                        requested_at: "2026-09-09T12:00:00Z"
                        decided_at: null
                        network: solana
                        address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"
                    updated_at: "2026-08-02T00:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/policy \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/policy", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/policy",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    patch:
      operationId: proposeWalletPolicyChange
      tags: [ Wallets ]
      summary: Change the policy of a managed wallet
      description: |
        Five fields, and they do not all behave the same way. **Read this split
        before you read anything else** - two of the five are requests, three take
        effect on this call.

        **`approvals`, `delay` and `cosigner` are yours to set, and TIGHTENING
        APPLIES IMMEDIATELY.** Requiring a second approval, holding sends for N
        hours, or putting your own co-signer in the path does not wait on OZAV:
        the object read by `GET /wallets/{wallet_id}/policy` moves on this
        response. Loosening is the direction that waits - a smaller quorum goes
        through the quorum in force, and a shorter delay is queued and only comes
        into force after the delay in force has elapsed. You cannot shorten a
        24-hour hold in less than 24 hours, which is the entire point of it.

        **`destinations` and `limits` are proposals.** They enter review by OZAV
        and nothing changes on this call; each appears at once in
        `pending_changes`, and when decided you receive `wallet_policy.updated`. A
        destination becomes effective the moment it is approved. A limit change is
        approved first and comes into force minutes later, when its signed record
        is issued - confirm it by reading `limits.valid_until`.

        `operationId` still says `propose`: it was named when the endpoint only
        carried the two proposal fields, and renaming it would break every
        generated client.

        Send at least one of `destinations` or `limits`. A destination already
        allowed, or already awaiting review, is not proposed again - the response
        still answers `202` with the policy as it stands. Missing limit fields
        inherit the values in force; when nothing is in force yet, both
        `per_transaction_usd` and `daily_usd` are required. A limit change needs
        a provisioned custody vault (`409 POLICY_LIMITS_REQUIRE_VAULT` before that);
        destinations can be proposed at any time.

        `Idempotency-Key` is **required** on every call: a proposal must not be
        enqueued twice by a retry, and a queued loosening must not have its clock
        restarted by one. In the sandbox the policy is read-only
        (`422 POLICY_SANDBOX_READ_ONLY`); use a live key.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          description: >
            Required idempotency key (16-256 characters of `[A-Za-z0-9_-]`) - a
            policy change is either enqueued or applied, and both must be safely
            retryable.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WalletPolicyChangeRequest" }
            examples:
              add_destination:
                summary: Allow a new payout address on Ethereum
                value:
                  destinations:
                    add:
                      - network: ethereum
                        address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
              raise_daily_limit:
                summary: Raise the daily limit, keeping the other numbers in force
                value:
                  limits:
                    daily_usd: 500000
              require_two_approvals:
                summary: Require a second approval above USD 10,000 - applies on this call
                description: >
                  Two of your API keys with `approvals:write` must accept before
                  a send above the threshold leaves. Tightening applies
                  immediately; lowering the number later goes through the quorum
                  in force.
                value:
                  approvals:
                    required_approvals: 2
                    above_usd: 10000
              hold_every_send_for_24h:
                summary: Hold sends for 24 hours before they leave - applies on this call
                description: >
                  The transfer answers `awaiting_delay` and you can stop it with
                  `POST /wallets/{wallet_id}/transfers/{transfer_id}/cancel` at
                  any point in the window. Shortening the delay later is queued
                  and only comes into force after the delay in force has
                  elapsed.
                value:
                  delay:
                    hours: 24
                    above_usd: null
              add_your_own_cosigner:
                summary: Put your own service in the signing path - applies on this call
                description: >
                  OZAV calls your endpoint before signing and will not sign
                  without your approval. `on_unavailable` is the decision that
                  matters: `hold` means a send waits when your service is
                  unreachable, `proceed` means it does not. The secret goes in
                  and never comes out - `GET /policy` shows the endpoint and
                  never the secret.
                value:
                  cosigner:
                    url: https://cosigner.example.com/ozav
                    secret: a-shared-secret-of-at-least-16-chars
                    on_unavailable: hold
                    timeout_ms: 3000
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            `POLICY_LIMITS_REQUIRE_VAULT` - the wallet has no custody vault yet,
            so it has no per-wallet limits to change; or
            `IDEMPOTENCY_KEY_REUSED`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: >
            `DESTINATION_ADDRESS_INVALID`, `NETWORK_NOT_SUPPORTED`,
            `POLICY_LIMITS_INCOMPLETE`, `POLICY_LIMITS_INVALID`,
            `WALLET_NOT_MANAGED` or `POLICY_SANDBOX_READ_ONLY`. `param` names
            the field.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "202":
          description: >
            The change is queued for review. The body is the policy as it stands
            now - the request appears in `pending_changes`; nothing in force has
            moved yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletPolicy" }
              examples:
                queued:
                  summary: The destination is awaiting review; the allowlist in force is unchanged
                  value:
                    object: wallet_policy
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    destinations:
                      mode: allowlist
                      allowed:
                        - network: ethereum
                          address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
                          added_at: "2026-08-01T00:00:00Z"
                    limits:
                      per_transaction_usd: 50000
                      daily_usd: 200000
                      auto_approve_below_usd: 1000
                      source: wallet
                      valid_until: "2027-02-01T00:00:00Z"
                    approvals:
                      mode: ozav_review
                    pending_changes:
                      - id: 7a2e3d4c-5b6f-4071-8b2c-3d4e5f6a7b8c
                        kind: destination.add
                        status: pending_review
                        requested_at: "2026-09-09T12:00:00Z"
                        decided_at: null
                        network: ethereum
                        address: "0x742d35cc6634c0532925a3b844bc454e4438f44e"
                    updated_at: "2026-08-01T00:00:00Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/policy \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "destinations": {
                  "add": [
                    {
                      "network": "ethereum",
                      "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
                    }
                  ]
                }
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/policy", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "destinations": {
                  "add": [
                    {
                      "network": "ethereum",
                      "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
                    }
                  ]
                }
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.patch(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/policy",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "destinations": {
                        "add": [
                            {
                                "network": "ethereum",
                                "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
                            },
                        ],
                    },
                },
            )

            data = response.json()
  /wallets/{wallet_id}/transfers:
    post:
      operationId: createManagedTransfer
      tags: [ Wallets ]
      summary: Send from a managed-custody wallet (OZAV signs)
      description: |
        For `managed` wallets only, where OZAV custodies the key. **OZAV signs and
        broadcasts this send.** It is the inverse of `transaction-intents`, which
        is for `self_custody` wallets, where the client signs.

        This is a real, irreversible money movement, so `Idempotency-Key` is
        **required** - a request without one is rejected rather than risking a
        second broadcast. Treat a timeout as UNKNOWN, never as failed: retry with
        the SAME key, which replays the stored result instead of sending again.

        The send is refused before anything is signed when the wallet's asset does
        not match `currency`, when the destination is not valid for the wallet's
        network, or when the corridor is not enabled for your organization.
        Nothing is broadcast on a refusal.

        **The same move is also available on `POST /transfers`**, with a `wallet`
        source and a `wallet` or `crypto_address` destination. Both routes drive
        the same executor and apply the same two controls (managed wallets only;
        Travel Rule on any send that leaves custody), so the choice is about which
        resource you want back, not about what happens on-chain:

        - Use **this route** when the send needs a destination `memo`. Networks
          that carry one (Stellar) need it for exchange deposits - an exchange
          credits by memo, and a deposit without one arrives unattributed.
          `POST /transfers` has no `memo` field.
        - Use **`POST /transfers`** when you want a `Transfer` resource, so the
          send appears in `GET /transfers` alongside your on-ramp, off-ramp and
          FX movements and is reconciled through the same status model.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          description: >
            Required idempotency key (16-256 characters of `[A-Za-z0-9_-]`) - a
            managed transfer is a money movement and must be safely retryable.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ currency, amount, destination ]
              properties:
                currency: { $ref: "#/components/schemas/Currency" }
                amount: { $ref: "#/components/schemas/DecimalAmount" }
                destination:
                  {
                    type: string,
                    description: "The recipient - a client-owned wallet id (ozav_wal_) or an
                      on-chain address valid for the wallet's chain."
                  }
                token_id:
                  type: string
                  pattern: '^ozav_tok_[0-9A-Za-z]{16,}$'
                  description: >-
                    Send a token you registered, instead of the asset OZAV backs
                    in this wallet. The id comes from `POST
                    /wallets/{wallet_id}/tokens`.


                    **Three things change, and you should plan for all three.**
                    `currency` must be the symbol you registered, not a currency
                    code. **Every send of a registered token needs approval from
                    your quorum** - OZAV does not price the token, so there is
                    no threshold to stay under, and a wallet with no approvals
                    quorum set is refused with `CLIENT_TOKEN_REQUIRES_QUORUM`.
                    And the token must be `active`: one still
                    `pending_signature` is refused, because OZAV's independent
                    co-signer does not know it yet.


                    Everything else is the same request it always was - the same
                    destination allowlist, the same Travel Rule, the same
                    mandatory delay, the same idempotency.
                  example: ozav_tok_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                nft_collection_id:
                  type: string
                  pattern: '^ozav_nft_[0-9A-Za-z]{16,}$'
                  description: >-
                    Send a piece from a collection you registered, instead of a
                    fungible asset. Comes with `nft_token_id`, and is mutually
                    exclusive with `token_id`.


                    **Every NFT send needs approval from your quorum** - OZAV
                    does not price a piece, so there is no threshold to stay
                    under, and a wallet with no approvals quorum is refused with
                    `NFT_TRANSFER_REQUIRES_QUORUM`. The collection must be
                    `active`; one still `pending_signature` is refused.
                  example: ozav_nft_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                nft_token_id:
                  type: string
                  pattern: '^(0|[1-9][0-9]{0,77})$'
                  description: >-
                    Which piece, as a decimal **string**. It is a string and not
                    a number on purpose: token ids routinely exceed 2^53, and a
                    JSON number would silently lose precision - sending a
                    different piece than you asked for.
                  example: "1234567890123456789012345"
                nft_quantity:
                  type: string
                  pattern: '^[1-9][0-9]{0,29}$'
                  description: >-
                    ERC-1155 only: how many units of that id. Omitted means 1.
                    An ERC-721 piece is indivisible, so any value other than 1
                    is refused.
                  example: "1"
                chain:
                  type: string
                  description: >-
                    Optional explicit chain for the send. When present, both the
                    source and the destination are resolved on THIS chain only,
                    and a mismatch fails before anything is signed or broadcast.
                    Supply it whenever the wallet holds the same asset on more
                    than one chain - omitting it lets the executor choose.
                  example: stellar
                memo:
                  type: string
                  minLength: 1
                  maxLength: 28
                  description: >-
                    Destination memo/tag, for networks whose transaction format
                    carries one (Stellar). **On a memo-based network the
                    recipient credits by memo: a send without it arrives and is
                    attributed to nobody.** Every major exchange deposit address
                    on Stellar is shared across its users, so sending there
                    without the memo the exchange gave you loses the deposit.
                    Read `memo_required` on `GET
                    /wallets/{wallet_id}/deposit-addresses` for the receiving
                    side of the same rule. 28 characters is the Stellar
                    `memo_text` ceiling; a longer value is rejected with `400`
                    naming the field, never truncated.
                  example: "2importmemo9"
                travel_rule: { $ref: "#/components/schemas/TravelRule" }
            examples:
              send:
                summary: Send 250 USDT from a managed wallet
                value:
                  currency: USDT
                  amount: "250.000000"
                  destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              send_memo_network:
                summary: Send on a memo-based network (Stellar) - the memo is what attributes it
                value:
                  currency: USDC
                  amount: "250.000000"
                  chain: stellar
                  destination: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
                  memo: "2importmemo9"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: The settled managed transfer.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, enum: [ wallet_transfer ] }
                  wallet_id: { type: string }
                  status: { type: string, enum: [ completed ] }
                  amount: { $ref: "#/components/schemas/DecimalAmount" }
                  currency: { $ref: "#/components/schemas/Currency" }
                  destination: { type: string }
                  transaction_hash: { type: string }
                  settled_at: { type: string, format: date-time }
              examples:
                settled:
                  summary: A settled managed USDT transfer
                  value:
                    id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: wallet_transfer
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: completed
                    amount: "250.000000"
                    currency: USDT
                    destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                    transaction_hash: "0x6c984fe16f15aa9865e7ca950c5038542e5789c8bd6dfc2fd95f649514\
                      a88b47"
                    settled_at: 2026-07-17T00:39:47Z
        "202":
          description: >
            **Held by your approval quorum.** The wallet's policy
            (`approvals.mode: customer_quorum`) requires approver keys to
            approve this send before OZAV signs it. Nothing has been signed.
            `approval.id` is what an approver key (`approvals:write`, a
            different key from the one that sent this request) passes to `POST
            /approvals/{approval_id}/approve`; the last required approval
            executes the send and you receive `transfer.approved`. Unapproved
            after `approval.expires_at` (72 h), it expires and nothing moves. A
            retry with the same `Idempotency-Key` returns this same held
            transfer.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema:
                type: object
                required:
                  [
                    id,
                    object,
                    wallet_id,
                    status,
                    amount,
                    currency,
                    destination,
                    approval
                  ]
                properties:
                  id: { type: string }
                  object: { type: string, enum: [ wallet_transfer ] }
                  wallet_id: { type: string }
                  status: { type: string, enum: [ awaiting_approval ] }
                  amount: { $ref: "#/components/schemas/DecimalAmount" }
                  currency: { $ref: "#/components/schemas/Currency" }
                  destination: { type: string }
                  transaction_hash: { type: "null" }
                  settled_at: { type: "null" }
                  approval:
                    type: object
                    required: [ id, required_approvals, expires_at ]
                    properties:
                      id: { type: string, description: "The `ozav_apr_` id to approve or reject." }
                      required_approvals: { type: integer }
                      expires_at: { type: string, format: date-time }
              examples:
                held:
                  summary: Held until two approver keys approve
                  value:
                    id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2
                    object: wallet_transfer
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: awaiting_approval
                    amount: "2500.000000"
                    currency: USDC
                    destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                    transaction_hash: null
                    settled_at: null
                    approval:
                      id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                      required_approvals: 2
                      expires_at: "2026-09-12T12:00:00Z"
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfers \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "currency": "USDT",
                "amount": "250.000000",
                "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfers", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "currency": "USDT",
                "amount": "250.000000",
                "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfers",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "currency": "USDT",
                    "amount": "250.000000",
                    "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                },
            )

            data = response.json()

  /wallets/{wallet_id}/nft-collections:
    get:
      operationId: listNftCollections
      tags: [ Wallets ]
      summary: List the NFT collections registered on a managed wallet
      description: |
        Lists the ERC-721 / ERC-1155 collections this wallet has registered.

        Each carries a `status`, and the middle one matters most:

        - `pending_signature` - registered here, but OZAV's independent co-signer
          does not know it yet, so **no transfer of a piece can be signed**.
        - `active` - the signed record exists.
        - `disabled` - switched off through this API, which is not revocation.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          schema: { type: string }
          description: The wallet id (`ozav_wal_...`).
      responses:
        "200":
          description: The registered collections.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [ list ] }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/NftCollection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    post:
      operationId: registerNftCollection
      tags: [ Wallets ]
      summary: Register an NFT collection
      description: |
        Registers an ERC-721 or ERC-1155 contract so this wallet can hold and move
        pieces from it.

        **Registering does not enable anything.** The record is created as
        `pending_signature`. OZAV's independent co-signer accepts a contract it
        does not already know only from a record signed on a machine the API
        cannot reach.

        **`padrao` has no default, on purpose.** It decides which function
        selector the co-signer requires - that is, how the same calldata bytes are
        read. Choosing one for you would let a 1155 collection be registered
        silently wrong, and you would find out as a refused transfer, far from the
        cause.

        **What this collection is not.** OZAV does not price it, does not credit
        it to your ledger balance, and does not include it in proof of reserves.
        Because it has no dollar value, **every transfer of a piece requires human
        approval from your quorum**, with no ceiling to configure.

        **Availability:** registration works end to end. The route that *moves* a
        piece does not exist yet.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          schema: { type: string }
          description: The wallet id (`ozav_wal_...`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ chain, contract, padrao, nome ]
              additionalProperties: false
              properties:
                chain:
                  type: string
                  description: Chain key, e.g. `ethereum`. EVM chains only for now.
                  example: ethereum
                contract:
                  type: string
                  pattern: "^0x[0-9a-fA-F]{40}$"
                  description: The collection contract address.
                  example: "0x1111111111111111111111111111111111111111"
                padrao:
                  type: string
                  enum: [ erc721, erc1155 ]
                  description: >-
                    The token standard. Required, with no default: it decides
                    which selector the co-signer requires.
                  example: erc721
                nome:
                  type: string
                  minLength: 1
                  maxLength: 64
                  description: >-
                    Display name. Read by people; it never decides anything.
                  example: Acervo do Cliente
            examples:
              erc721:
                summary: An ERC-721 collection
                value:
                  chain: ethereum
                  contract: "0x1111111111111111111111111111111111111111"
                  padrao: erc721
                  nome: Acervo do Cliente
      responses:
        "202":
          description: >-
            Registered as a proposal. `202` and not `201`: the collection exists
            but cannot be used until the signed record is issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NftCollection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This contract is already registered for this wallet on this chain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: >-
            The collection cannot be registered - a contract that already
            belongs to an asset OZAV backs is refused.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "chain": "ethereum",
                "contract": "0x1111111111111111111111111111111111111111",
                "padrao": "erc721",
                "nome": "Acervo do Cliente"
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "chain": "ethereum",
                "contract": "0x1111111111111111111111111111111111111111",
                "padrao": "erc721",
                "nome": "Acervo do Cliente"
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "chain": "ethereum",
                    "contract": "0x1111111111111111111111111111111111111111",
                    "padrao": "erc721",
                    "nome": "Acervo do Cliente",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/nft-collections/{collection_id}/disable:
    post:
      operationId: disableNftCollection
      tags: [ Wallets ]
      summary: Stop accepting a registered NFT collection
      description: |
        Switches a registered collection off. New transfers from it are no longer
        accepted through this API.

        **This is not revocation.** The signed record OZAV's co-signer holds keeps
        its own validity window. The row is kept, not deleted, so past transfers
        that reference this collection id stay explainable.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          schema: { type: string }
          description: The wallet id (`ozav_wal_...`).
        - name: collection_id
          in: path
          required: true
          schema: { type: string }
          description: The collection id (`ozav_nft_...`).
      responses:
        "200":
          description: The collection, now `disabled`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NftCollection" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections/{collection_id}/disable \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections/{collection_id}/disable", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/nft-collections/{collection_id}/disable",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/tokens:
    get:
      operationId: listClientTokens
      tags: [ Wallets ]
      summary: List the tokens registered on a managed wallet
      description: |
        Lists the ERC-20 tokens this wallet has registered beyond the assets OZAV
        prices and backs.

        Each token carries a `status`, and the middle one matters most:

        - `pending_signature` - registered here, but OZAV's independent co-signer
          does not know it yet, so **no transfer of it can be signed**.
        - `active` - the signed record exists.
        - `disabled` - switched off through this API. This is not revocation: the
          signed record keeps its own validity window.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          schema: { type: string }
          description: The wallet id (`ozav_wal_...`).
      responses:
        "200":
          description: The registered tokens.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [ list ] }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ClientToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    post:
      operationId: registerClientToken
      tags: [ Wallets ]
      summary: Register a token OZAV does not price or back
      description: |
        Registers an ERC-20 contract so this wallet can hold and move it.

        **Registering does not enable anything, and that is the point.** The
        record is created as `pending_signature`. OZAV's independent co-signer
        accepts a contract it does not already know only from a record signed on
        a machine the API cannot reach - so until that record is issued, every
        transfer of this token is refused.

        **What OZAV will not accept.** A symbol OZAV prices and backs (`USDC`,
        `USDT`, `BRS`, and the native coins) is refused; so is a contract that
        already belongs to one of those assets, whatever symbol you give it. Both
        would let real, priced money move under rules written for an asset with
        no price.

        **What this token is not.** OZAV does not price it, does not credit it to
        your ledger balance, and does not include it in proof of reserves - the
        on-chain balance is the only record. Because it has no dollar value,
        **every transfer of it requires human approval from your quorum**, with
        no ceiling to configure.

        **Availability:** registration works end to end. The route that *moves*
        one of these tokens does not exist yet. Read `GET /capabilities` rather
        than discovering this on your first transfer.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          schema: { type: string }
          description: The wallet id (`ozav_wal_...`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ chain, contract, symbol, decimals ]
              additionalProperties: false
              properties:
                chain:
                  type: string
                  description: Chain key, e.g. `ethereum`. EVM chains only for now.
                  example: ethereum
                contract:
                  type: string
                  pattern: '^0x[0-9a-fA-F]{40}$'
                  description: The ERC-20 contract address.
                  example: '0x1111111111111111111111111111111111111111'
                symbol:
                  type: string
                  pattern: '^[A-Z0-9][A-Z0-9._-]{0,15}$'
                  description: |
                    Display symbol, uppercase. Used for reading, never to decide
                    anything. A symbol OZAV prices and backs is refused.
                  example: MEUTOKEN
                decimals:
                  type: integer
                  minimum: 0
                  maximum: 36
                  description: |
                    Decimal places. Signed together with the contract - OZAV never
                    reads `decimals()` from a node at send time.
                  example: 18
            examples:
              erc20:
                summary: An ERC-20 the client issued
                value:
                  chain: ethereum
                  contract: '0x1111111111111111111111111111111111111111'
                  symbol: MEUTOKEN
                  decimals: 18
      responses:
        "202":
          description: |
            Registered as a proposal. `202` and not `201`: the token exists but
            cannot be used until the signed record is issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClientToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This contract is already registered for this wallet on this chain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: |
            The token cannot be registered - a symbol OZAV prices and backs, or a
            contract that already belongs to one of those assets.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "chain": "ethereum",
                "contract": "0x1111111111111111111111111111111111111111",
                "symbol": "MEUTOKEN",
                "decimals": 18
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "chain": "ethereum",
                "contract": "0x1111111111111111111111111111111111111111",
                "symbol": "MEUTOKEN",
                "decimals": 18
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "chain": "ethereum",
                    "contract": "0x1111111111111111111111111111111111111111",
                    "symbol": "MEUTOKEN",
                    "decimals": 18,
                },
            )

            data = response.json()
  /wallets/{wallet_id}/tokens/{token_id}/disable:
    post:
      operationId: disableClientToken
      tags: [ Wallets ]
      summary: Stop accepting a registered token
      description: |
        Switches a registered token off. New transfers of it are no longer
        accepted through this API.

        **This is not revocation.** The signed record OZAV's co-signer holds keeps
        its own validity window; switching off here governs what this API accepts
        from now on. The row is kept, not deleted, so past transfers that
        reference this token id stay explainable.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          schema: { type: string }
          description: The wallet id (`ozav_wal_...`).
        - name: token_id
          in: path
          required: true
          schema: { type: string }
          description: The token id (`ozav_tok_...`).
      responses:
        "200":
          description: The token, now `disabled`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClientToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens/{token_id}/disable \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens/{token_id}/disable", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/tokens/{token_id}/disable",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/contract-calls:
    post:
      operationId: createContractCall
      tags: [ Wallets ]
      summary: Call a contract from a managed wallet (allowed calls only)
      description: |
        Calls a smart contract from a managed-custody wallet - but **only a call
        that is on OZAV's signed allowed-call list, and only with the arguments
        that list left open.** The list is a single OZAV-wide list, not a
        per-organization one.

        **There is no `data` field, and that is the point.** You do not build the
        calldata; you *name* the call. OZAV looks up the signed rule for
        (`chain`, `contract`, `selector`), and assembles the calldata itself:
        every argument the rule pinned comes from the rule, and `arguments` fills
        only the positions the rule left free. There is no request that produces
        calldata outside the list.

        A pinned argument is how "only this vault, only `deposit`, only to me"
        is expressed: the rule can fix an argument to an exact value, or to the
        calling wallet's own address. You cannot reach those positions.

        **Two whole classes of function can never be on the list**, no matter who
        signs it: the ones that move value on their own (`transfer`,
        `transferFrom`, the `safeTransferFrom` family) - those go through the
        transfer route, with its destination allowlist, asset and ceiling - and
        the ones that delegate spending power (`approve`, `increaseAllowance`,
        `setApprovalForAll`), which are accepted only with the spender pinned.

        `Idempotency-Key` is **required**: a contract call moves money.

        **Availability:** the allowed-call list starts empty, so every call is
        refused with `CONTRACT_CALL_NOT_ALLOWED` until an entry is added. And
        dispatch is not wired yet: a fully valid request answers `503
        CONTRACT_CALL_NOT_CONFIGURED` and **nothing is signed**. Read
        `GET /capabilities` rather than discovering this on your first call.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          description: >
            Required idempotency key (16-256 characters of `[A-Za-z0-9_-]`) - a
            contract call is a money movement and must be safely retryable.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ chain, contract, selector, arguments ]
              properties:
                chain:
                  type: string
                  description: >-
                    The EVM network. The rule is pinned to a chain id, not to a
                    name: with CREATE2 the same address on another chain can be
                    an entirely different contract.
                  example: polygon
                contract:
                  type: string
                  pattern: "^0x[0-9a-fA-F]{40}$"
                  description: The contract to call. It must be on the signed list for this chain.
                selector:
                  type: string
                  pattern: "^0x[0-9a-fA-F]{8}$"
                  description: >-
                    The 4-byte function selector. You name the function; you do
                    not encode the call.
                  example: "0x6e553f65"
                arguments:
                  type: array
                  maxItems: 16
                  items:
                    type: string
                    pattern: "^0x[0-9a-fA-F]{64}$"
                  description: >-
                    One 32-byte hex word per **free** position in the rule, in
                    the order those positions appear. Too many or too few is
                    rejected - a missing value is never filled with zeros,
                    because that would be OZAV choosing an argument for you.
            examples:
              deposit_to_self:
                summary: Deposit into an allowed vault, with the receiver pinned to your own
                  wallet
                value:
                  chain: polygon
                  contract: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
                  selector: "0x6e553f65"
                  arguments:
                    - "0x000000000000000000000000000000000000000000000000000000\
                      00000f4240"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        # Duas causas, o mesmo 503, e as duas dizem a mesma coisa que importa: NADA foi assinado.
        # `CONTRACT_CALL_LIST_UNAVAILABLE` - a lista assinada nao pode ser lida.
        # `CONTRACT_CALL_NOT_CONFIGURED` - o despacho ainda nao existe.
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/contract-calls \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "chain": "polygon",
                "contract": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                "selector": "0x6e553f65",
                "arguments": [
                  "0x00000000000000000000000000000000000000000000000000000000000f4240"
                ]
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/contract-calls", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "chain": "polygon",
                "contract": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                "selector": "0x6e553f65",
                "arguments": [
                  "0x00000000000000000000000000000000000000000000000000000000000f4240"
                ]
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/contract-calls",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "chain": "polygon",
                    "contract": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "selector": "0x6e553f65",
                    "arguments": [
                        "0x00000000000000000000000000000000000000000000000000000000000f4240",
                    ],
                },
            )

            data = response.json()
  /wallets/{wallet_id}/typed-messages:
    post:
      operationId: createTypedMessage
      tags: [ Wallets ]
      summary: Sign an EIP-712 typed message from a managed wallet (allowed messages
        only)
      description: |
        Signs an EIP-712 typed message with a managed-custody wallet's key - but
        **only a message that is on OZAV's signed allowed-message list, and only
        with the fields that list left open.** The list is a single OZAV-wide
        list, not a per-organization one.

        **There is no digest field, and that is the whole point.** An EIP-712
        signature has no envelope, no `to` and no `value`: what gets signed is a
        raw 32-byte payload, and on a secp256k1 key raw bytes are
        indistinguishable from the hash of a transaction. An endpoint that
        accepted "sign these bytes" would be an endpoint that drains wallets
        under another name. You *name* the message; OZAV derives the digest from
        the signed rule, and the independent co-signer **re-derives it** and
        refuses unless it matches what the key is actually about to sign.

        A pinned field is how "only this spender, only to me" is expressed: the
        rule can fix a field to an exact value, or to the signing wallet's own
        address. You cannot reach those positions.

        **Message types that delegate spending power can never be listed with a
        free beneficiary** - the ERC-2612 and DAI `Permit`, and all three Permit2
        forms (`PermitSingle`, `PermitBatch`, `PermitTransferFrom`). A `Permit`
        with a free `spender` is an `approve` signed for free: no gas, no block,
        no envelope guard.

        `Idempotency-Key` is **required**: a signed permit delegates money.

        **Availability:** the allowed-message list starts empty, so every request
        is refused with `TYPED_MESSAGE_NOT_ALLOWED` until an entry is added. And
        dispatch is not wired yet: a fully valid request answers `503
        TYPED_MESSAGE_NOT_CONFIGURED` and **nothing is signed**. Read
        `GET /capabilities` rather than discovering this on your first call.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: coming_soon
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          description: >
            Required idempotency key (16-256 characters of `[A-Za-z0-9_-]`) - a
            signed permit delegates spending power and must be safely retryable.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ chain, domain_separator, type_hash, values ]
              properties:
                chain:
                  type: string
                  description: >-
                    The EVM network. The rule is pinned to a chain id, not to a
                    name.
                  example: polygon
                domain_separator:
                  type: string
                  pattern: "^0x[0-9a-fA-F]{64}$"
                  description: >-
                    The EIP-712 `domainSeparator` - `hashStruct(EIP712Domain)`,
                    which binds name, version, chain and verifying contract. It
                    must be on the signed list for this chain.
                type_hash:
                  type: string
                  pattern: "^0x[0-9a-fA-F]{64}$"
                  description: >-
                    The `typeHash` of the primary type. You name the message
                    type; you do not encode the message.
                values:
                  type: array
                  maxItems: 16
                  items:
                    type: string
                    pattern: "^0x[0-9a-fA-F]{64}$"
                  description: >-
                    One 32-byte hex word per **free** position in the rule, in
                    the order those positions appear. Dynamic types (`string`,
                    `bytes`, arrays) enter `encodeData` already hashed, so they
                    are words too. Too many or too few is rejected - a missing
                    value is never filled with zeros, because that would be OZAV
                    choosing a field for you.
            examples:
              permit_with_spender_pinned:
                summary: An ERC-2612 permit whose owner and spender the rule pinned; you supply
                  value, nonce and deadline
                value:
                  chain: polygon
                  domain_separator: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\
                    bbbbbb"
                  type_hash: "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9"
                  values:
                    - "0x000000000000000000000000000000000000000000000000000000\
                      00000f4240"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        # Duas causas, o mesmo 503, e as duas dizem o que importa: NADA foi assinado.
        # `TYPED_MESSAGE_LIST_UNAVAILABLE` - a lista assinada nao pode ser lida.
        # `TYPED_MESSAGE_NOT_CONFIGURED` - o despacho ainda nao existe.
        "503": { $ref: "#/components/responses/DependencyUnavailable" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/typed-messages \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "chain": "polygon",
                "domain_separator": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                "type_hash": "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9",
                "values": [
                  "0x00000000000000000000000000000000000000000000000000000000000f4240"
                ]
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/typed-messages", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "chain": "polygon",
                "domain_separator": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                "type_hash": "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9",
                "values": [
                  "0x00000000000000000000000000000000000000000000000000000000000f4240"
                ]
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/typed-messages",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "chain": "polygon",
                    "domain_separator": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "type_hash": "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9",
                    "values": [
                        "0x00000000000000000000000000000000000000000000000000000000000f4240",
                    ],
                },
            )

            data = response.json()
  /wallets/{wallet_id}/transfers/{transfer_id}/cancel:
    post:
      operationId: cancelDelayedWalletTransfer
      tags: [ Wallets ]
      summary: Cancel a transfer that is waiting on its delay
      description: |
        Stops a transfer that is `awaiting_delay` before it leaves. **This is the
        whole point of the delay**: holding a send for N hours only helps if
        someone can read the statement, not recognise the withdrawal, and stop
        it.

        Only a transfer waiting on the CLOCK can be cancelled here. One waiting
        on your approval quorum is stopped by rejecting its approval
        (`POST /approvals/{approval_id}/reject`), where the rule about who may
        reject already lives.

        Anything else - already sent, already cancelled, or an id that is not
        yours - answers `409 TRANSFER_NOT_CANCELLABLE`. The four are one refusal
        on purpose: telling them apart would tell you which ids exist in other
        organizations.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: transfer_id
          in: path
          required: true
          description: The transfer to cancel (`ozav_wtr_...`).
          schema: { type: string }
      responses:
        "200":
          description: The transfer is cancelled. Nothing was sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, enum: [ wallet_transfer ] }
                  status: { type: string, enum: [ cancelled ] }
              example:
                id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                object: wallet_transfer
                status: cancelled
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            `TRANSFER_NOT_CANCELLABLE` - the transfer is not waiting on a delay.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/cancel \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/cancel", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1/cancel",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /wallets/{wallet_id}/transfer-batches:
    post:
      operationId: createManagedTransferBatch
      tags: [ Wallets ]
      summary: Send to many destinations in one call (OZAV signs each leg)
      description: |
        One call, one `Idempotency-Key`, one policy decision, one approval - for
        up to 50 sends from the same managed wallet, on the same network, in the
        same currency.

        **What this replaces.** Without it you write the loop yourself, and each
        turn of your loop is a separate policy decision: a separate approval to
        chase, a separate idempotency key to track, and a separate answer to
        reconcile. A batch is one decision over the whole payout run.

        **The legs run one at a time, in order, and that is deliberate.** A
        transaction is built against the sending address's pending nonce, so two
        sends from the same wallet cannot be in flight at once without building
        the same transaction twice. The batch is not faster than your own loop -
        it is one decision instead of many.

        **It stops at the first leg that does not settle.** Travel Rule is
        checked for **every** leg before the first one is signed, so an
        attestation gap cannot strand a half-finished run. Everything else -
        address validity, the destination wallet, currency against the wallet's
        asset, balance, freezes, gas, compliance holds - is checked per leg, as
        it is for a single send, so **a bad destination in leg 5 can stop a run
        whose first four legs already moved real money.** Read `legs[]` for what
        actually happened; never assume a batch is all-or-nothing.

        Legs after the failure come back `cancelled`, which means **not
        attempted** - never `failed`.

        **`needs_review` is not a failure.** It means the send may have been
        broadcast and the record did not close. Never re-send that leg on the
        strength of this response: read the batch back, or reconcile from the
        leg's own `wallet_transfer` id.

        **Your approval quorum sees the TOTAL.** If the wallet's policy sets
        `approvals.above_usd`, the threshold is applied to the sum of the legs,
        not to each leg. Ten legs of 9,000 under a 10,000 threshold require
        approval, because the batch is a 90,000 movement.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:write ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: Idempotency-Key
          in: header
          required: true
          description: >
            Required idempotency key (16-256 characters of `[A-Za-z0-9_-]`). One
            key covers the whole batch: a retry with the same key returns the
            same batch and never re-sends a leg that already settled.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ currency, legs ]
              properties:
                currency: { $ref: "#/components/schemas/Currency" }
                chain:
                  type: string
                  description: >-
                    Optional explicit chain for the whole batch. Every leg is
                    resolved on THIS chain - a batch never spans networks,
                    because the pending nonce that orders the legs belongs to
                    one address on one chain.
                  example: polygon
                legs:
                  type: array
                  minItems: 1
                  maxItems: 50
                  description: >-
                    The sends, in the order they will be attempted. 50 is the
                    ceiling because the legs run inside one request: a longer
                    batch would run out of time mid-run, with part of the money
                    already sent and no answer returned to you.
                  items:
                    type: object
                    additionalProperties: false
                    required: [ destination, amount ]
                    properties:
                      destination:
                        type: string
                        description: "The recipient - a client-owned wallet id (ozav_wal_) or an
                          on-chain address valid for the batch's chain."
                      amount: { $ref: "#/components/schemas/DecimalAmount" }
                      memo:
                        type: string
                        minLength: 1
                        maxLength: 28
                        description: >-
                          Destination memo/tag, per leg, for networks that carry
                          one (Stellar). On a memo-based network the recipient
                          credits by memo, so a leg without it arrives
                          attributed to nobody.
                      travel_rule: { $ref: "#/components/schemas/TravelRule" }
            examples:
              payout_run:
                summary: Pay three suppliers in one decision
                value:
                  currency: USDC
                  chain: polygon
                  legs:
                    - destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                      amount: "1200.000000"
                    - destination: "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9"
                      amount: "845.500000"
                    - destination: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V7
                      amount: "300.000000"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: >
            The batch ran. Read `status` for the batch and `legs[].status` for
            each send. `partially_completed` means some legs settled and some
            did not - the settled ones moved real money and are not reversible.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletTransferBatch" }
              examples:
                completed:
                  summary: All three legs settled
                  value:
                    id: ozav_wtb_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: wallet_transfer_batch
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: completed
                    currency: USDC
                    total_amount: "2345.5"
                    leg_count: 3
                    legs:
                      - index: 0
                        id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2
                        status: completed
                        amount: "1200.000000"
                        destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                        transaction_hash: "0x6c984fe16f15aa9865e7ca950c5038542e5789c8bd6dfc2fd95f649514\
                          a88b47"
                      - index: 1
                        id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                        status: completed
                        amount: "845.500000"
                        destination: "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9"
                        transaction_hash: "0x8a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6\
                          d7e8f9"
                      - index: 2
                        id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V4
                        status: completed
                        amount: "300.000000"
                        destination: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V7
                        transaction_hash: "0x9b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7\
                          e8f90"
                partially_completed:
                  summary: >-
                    Leg 1 ran out of balance; leg 2 was never attempted.
                    `cancelled` is not `failed` - nothing was sent for it.
                  value:
                    id: ozav_wtb_01J8Z9K2M3N4P5Q6R7S8T9U0V5
                    object: wallet_transfer_batch
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: partially_completed
                    currency: USDC
                    total_amount: "2345.5"
                    leg_count: 3
                    legs:
                      - index: 0
                        id: ozav_wtr_01J8Z9K2M3N4P5Q6R7S8T9U0V2
                        status: completed
                        amount: "1200.000000"
                        destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                        transaction_hash: "0x6c984fe16f15aa9865e7ca950c5038542e5789c8bd6dfc2fd95f649514\
                          a88b47"
                      - index: 1
                        id: null
                        status: failed
                        amount: "845.500000"
                        destination: "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9"
                        transaction_hash: null
                        failure:
                          code: INSUFFICIENT_BALANCE
                          message: The wallet does not hold enough of this asset for this send.
                      - index: 2
                        id: null
                        status: cancelled
                        amount: "300.000000"
                        destination: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V7
                        transaction_hash: null
        "202":
          description: >
            **Held by your approval quorum, as one decision over the whole
            batch.** The threshold was applied to `total_amount`, not to any
            single leg. Nothing has been signed and every leg reads `pending`.
            `approval.id` is what an approver key (`approvals:write`, a
            different key from the one that sent this request) passes to `POST
            /approvals/{approval_id}/approve`; the last required approval runs
            the whole batch. Unapproved after `approval.expires_at` (72 h) it
            expires and nothing moves.
          headers:
            Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletTransferBatch" }
              examples:
                held:
                  summary: Ten legs of 9,000 sum to 90,000 and cross a 10,000 threshold
                  value:
                    id: ozav_wtb_01J8Z9K2M3N4P5Q6R7S8T9U0V6
                    object: wallet_transfer_batch
                    wallet_id: ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    status: awaiting_approval
                    currency: USDC
                    total_amount: "90000"
                    leg_count: 2
                    legs:
                      - index: 0
                        id: null
                        status: pending
                        amount: "45000.000000"
                        destination: "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8"
                        transaction_hash: null
                      - index: 1
                        id: null
                        status: pending
                        amount: "45000.000000"
                        destination: "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9"
                        transaction_hash: null
                    approval:
                      id: ozav_apr_01J8Z9K2M3N4P5Q6R7S8T9U0V3
                      required_approvals: 2
                      expires_at: 2026-09-13T12:00:00Z
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfer-batches \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Idempotency-Key: idem-6f9c24e5b1a74d38" \
              -H "Content-Type: application/json" \
              -d '{
                "currency": "USDC",
                "chain": "polygon",
                "legs": [
                  {
                    "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                    "amount": "1200.000000"
                  },
                  {
                    "destination": "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9",
                    "amount": "845.500000"
                  },
                  {
                    "destination": "ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V7",
                    "amount": "300.000000"
                  }
                ]
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfer-batches", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "currency": "USDC",
                "chain": "polygon",
                "legs": [
                  {
                    "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                    "amount": "1200.000000"
                  },
                  {
                    "destination": "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9",
                    "amount": "845.500000"
                  },
                  {
                    "destination": "ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V7",
                    "amount": "300.000000"
                  }
                ]
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfer-batches",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Idempotency-Key": "idem-6f9c24e5b1a74d38",
                    "Content-Type": "application/json",
                },
                json={
                    "currency": "USDC",
                    "chain": "polygon",
                    "legs": [
                        {
                            "destination": "0x1d4c9ae2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8",
                            "amount": "1200.000000",
                        },
                        {
                            "destination": "0x2e5daaf3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9",
                            "amount": "845.500000",
                        },
                        {
                            "destination": "ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V7",
                            "amount": "300.000000",
                        },
                    ],
                },
            )

            data = response.json()
  /wallets/{wallet_id}/transfer-batches/{batch_id}:
    get:
      operationId: getManagedTransferBatch
      tags: [ Wallets ]
      summary: Read a transfer batch
      description: |
        The batch as it stands, with one entry per leg you asked for - including
        the legs that were never attempted, so the count always matches what you
        sent. Read this after a timeout, or while a batch waits on your approval
        quorum.

        A leg with `id: null` does not exist anywhere: nothing was sent for it.
        A leg with an `id` is a real `wallet_transfer` you can reconcile.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ wallets:read ]
      x-ozav-availability: live
      parameters:
        - name: wallet_id
          in: path
          required: true
          description: The opaque wallet id (`ozav_wal_...`).
          schema: { type: string }
        - name: batch_id
          in: path
          required: true
          description: The opaque batch id (`ozav_wtb_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The batch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WalletTransferBatch" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfer-batches/{batch_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfer-batches/{batch_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/wallets/ozav_wal_01J8Z9K2M3N4P5Q6R7S8T9U0V1/transfer-batches/{batch_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /api-keys:
    post:
      operationId: createApiKey
      tags: [ API Keys ]
      summary: Create an API key (self-serve)
      description: |
        Creates a new API key scoped to a subset of your entitlements. The full
        `secret` is returned exactly once, in this response - store it securely,
        as it cannot be retrieved again.

        **Give it either `role` or `scopes` - exactly one.**

        - **`role`** is the shortcut: `admin`, `operator` or `viewer`. Use it to
          separate powers between the people and systems on your side without
          picking 32 scopes by hand. A role is a **subset of what the minting key
          already holds** - never a way to get more - so a key minted with
          `role: admin` by a narrow key is still narrow.
        - **`scopes`** is the explicit list, intersected with the organization's
          entitlements at request time; an empty set is rejected.

        Sending both, or neither, is a `400`: a permission object should not have
        an implicit default, and there is no sensible rule for which one wins.
      x-ozav-entitlement: developer
      x-ozav-scopes: [ keys:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ name ]
              # O XOR entre `role` e `scopes`, ENCODADO e não só descrito em prosa. Sem isto o
              # contrato publicado (e o `.d.ts` gerado dele) valida `{"name":"k"}`, um corpo que a
              # API recusa com 400 - e qualquer cliente gerado ou teste de contrato o consideraria
              # bom. `oneOf` porque exatamente um dos dois é exigido, nunca os dois, nunca nenhum.
              oneOf:
                - required: [ role ]
                  not: { required: [ scopes ] }
                - required: [ scopes ]
                  not: { required: [ role ] }
              properties:
                name:
                  {
                    type: string,
                    description: "A human-readable label for the key, to identify it later."
                  }
                role:
                  type: string
                  enum: [ admin, operator, viewer ]
                  description: |
                    The key's role - a template over the scopes the minting key
                    already holds. Mutually exclusive with `scopes`.

                    - `admin` - everything the minting key holds.
                    - `operator` - everything except `keys:write`, so the key
                      cannot mint or revoke other keys. It keeps `keys:read` (it
                      still sees which keys exist) and `approvals:write`, because
                      approving a held send is the operational act this role
                      exists to name.
                    - `viewer` - read scopes only. A viewer key cannot approve.

                    A role is a **subset** of the minting key's scopes: it never
                    grants more than the caller already had.

                    The role is stored and echoed back on the key, and a rotated
                    key keeps it. It is a label and a mint-time template - it is
                    **not** part of the permission decision, which stays
                    `entitlements ∩ scopes`.
                environment:
                  type: string
                  description: |
                    Which environment the new key is for - `test` (sandbox) or
                    `live` (production). The prefix of the returned key reflects this.
                  enum: [ test, live ]
                scopes:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum:
                      - customers:read
                      - customers:write
                      - accounts:read
                      - accounts:write
                      - quotes:read
                      - quotes:write
                      - transfers:read
                      - transfers:write
                      - destinations:read
                      - destinations:write
                      - wallets:read
                      - wallets:write
                      - screenings:read
                      - screenings:write
                      - verifications:read
                      - verifications:write
                      - cards:read
                      - cards:write
                      - webhooks:read
                      - webhooks:write
                      - events:read
                      - keys:read
                      - keys:write
                      - transactions:read
                      - yield:read
                      - yield:write
                      - yield:apply
                      - swap:read
                      - network_transfers:read
                      - network_transfers:write
                      - approvals:read
                      - approvals:write
                      - evidence:read
                  description: |
                    Scopes are intersected with the organization's entitlements at request time; an
                    empty scope set is invalid. This is a **closed set**: a value outside the enum
                    above is **rejected** with `400 INVALID_SCOPE`, and the error lists every valid
                    scope - a typo fails loudly at mint time rather than quietly minting a key with
                    a narrower scope set than you asked for. You can only grant scopes the minting
                    key itself holds (`403 SCOPE_NOT_HELD` otherwise).
            examples:
              operator_role:
                summary: An operator key, without listing scopes by hand
                value:
                  name: payments operator
                  environment: live
                  role: operator
              live_key:
                summary: A live key scoped to move money
                value:
                  name: server-side production key
                  environment: live
                  scopes:
                    - quotes:write
                    - transfers:write
                    - transfers:read
                    - customers:read
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Key created; the secret is returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
              examples:
                created:
                  summary: Secret returned once - store it securely
                  value:
                    id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                    object: api_key
                    name: server-side production key
                    key_prefix: ozav_sk_live_a1b2
                    environment: live
                    scopes:
                      - quotes:write
                      - transfers:write
                      - transfers:read
                      - customers:read
                    created_at: 2026-07-12T18:00:00Z
                    secret: ozav_sk_live_a1b2c3d4e5f60718293a4b5c6d7e8f90
        "400": { $ref: "#/components/responses/BadRequest" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/api-keys \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "name": "payments operator",
                "environment": "live",
                "role": "operator"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/api-keys", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "name": "payments operator",
                "environment": "live",
                "role": "operator"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/api-keys",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "name": "payments operator",
                    "environment": "live",
                    "role": "operator",
                },
            )
            data = response.json()
    get:
      operationId: listApiKeys
      tags: [ API Keys ]
      summary: List API keys (prefixes only)
      description: |
        Lists your API keys with their non-secret `key_prefix`, scopes, and
        environment. Secrets are never returned here - a secret is shown only at
        creation and rotation. Live.
      x-ozav-entitlement: any
      x-ozav-scopes: [ keys:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Keys (no secrets).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
              examples:
                keys:
                  summary: Your keys, prefixes only
                  value:
                    data:
                      - id: ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1
                        object: api_key
                        name: server-side production key
                        key_prefix: ozav_sk_live_a1b2
                        environment: live
                        scopes: [ quotes:write, transfers:write, transfers:read, customers:read ]
                        last_used_at: 2026-07-12T17:59:00Z
                        created_at: 2026-07-12T18:00:00Z
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/api-keys \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/api-keys", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/api-keys",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /api-keys/{api_key_id}:
    delete:
      operationId: revokeApiKey
      tags: [ API Keys ]
      summary: Revoke an API key
      description: |
        Revokes an API key immediately; subsequent requests presenting that key
        fail authentication. Returns `204 No Content`. Irreversible - mint a new
        key to replace a revoked one. Live.
      x-ozav-entitlement: any
      x-ozav-scopes: [ keys:write ]
      x-ozav-availability: live
      parameters:
        - name: api_key_id
          in: path
          required: true
          description: The opaque API-key id (`ozav_key_...`).
          schema: { type: string }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "204":
          description: Revoked.
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/api-keys/ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -X DELETE \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/api-keys/ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "DELETE",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.delete(
                "https://developers.ozav.io/v2/api-keys/ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /api-keys/{api_key_id}/rotate:
    post:
      operationId: rotateApiKey
      tags: [ API Keys ]
      summary: Rotate an API key with an overlap window
      description: |
        Mints a successor key with identical scopes. The previous secret
        remains valid for the overlap window (default 24 hours, maximum 7
        days), then is revoked automatically. Live.
      x-ozav-entitlement: any
      x-ozav-scopes: [ keys:write ]
      x-ozav-availability: live
      parameters:
        - name: api_key_id
          in: path
          required: true
          description: The opaque API-key id (`ozav_key_...`).
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                overlap_hours:
                  {
                    type: integer,
                    minimum: 0,
                    maximum: 168,
                    default: 24,
                    description: "How long the previous secret stays valid after rotation, in hours
                      (0-168; default 24). It is revoked automatically when the
                      window elapses."
                  }
            examples:
              overlap:
                summary: 48-hour overlap window
                value:
                  overlap_hours: 48
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Successor key; secret returned once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
              examples:
                rotated:
                  summary: Successor key - store the new secret; the old one stays valid for the
                    overlap window
                  value:
                    id: ozav_key_02J8Z9K2M3N4P5Q6R7S8T9U0V2
                    object: api_key
                    name: server-side production key
                    key_prefix: ozav_sk_live_c3d4
                    environment: live
                    scopes: [ quotes:write, transfers:write, transfers:read, customers:read ]
                    created_at: 2026-07-12T18:20:00Z
                    secret: ozav_sk_live_c3d4e5f60718293a4b5c6d7e8f90a1b2
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/api-keys/ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1/rotate \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "overlap_hours": 48
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/api-keys/ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1/rotate", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "overlap_hours": 48
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/api-keys/ozav_key_01J8Z9K2M3N4P5Q6R7S8T9U0V1/rotate",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "overlap_hours": 48,
                },
            )

            data = response.json()

  /webhook-endpoints:
    post:
      operationId: createWebhookEndpoint
      tags: [ Webhooks ]
      summary: Register a webhook endpoint
      description: |
        Registers an HTTPS endpoint to receive signed event notifications for the
        subscribed `enabled_events`. The signing `secret` (`ozav_whsec_`) is
        returned exactly once, in this response - store it securely; it is used to
        verify delivery signatures and cannot be retrieved again.

        The hostname is checked at registration. A host that does not resolve is
        refused with `422 WEBHOOK_URL_HOST_NOT_FOUND`, because a typo there fails
        silently forever: deliveries never reach anything and the only trace is a
        null `response_status` in `GET /webhook-endpoints/{id}/deliveries`. Only a
        non-existent hostname is refused - an endpoint behind a firewall, not yet
        deployed, or slow to answer registers normally.
      x-ozav-entitlement: developer
      x-ozav-scopes: [ webhooks:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreateRequest" }
            examples:
              basic:
                summary: Subscribe to customer + screening events
                value:
                  url: https://api.acme.com/ozav/webhooks
                  enabled_events: [ customer.created, screening.completed ]
                  description: production
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "201":
          description: Endpoint registered; the signing secret is in `secret` (shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
        "400": { $ref: "#/components/responses/BadRequest" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "url": "https://api.acme.com/ozav/webhooks",
                "enabled_events": [
                  "customer.created",
                  "screening.completed"
                ],
                "description": "production"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/webhook-endpoints", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "url": "https://api.acme.com/ozav/webhooks",
                "enabled_events": [
                  "customer.created",
                  "screening.completed"
                ],
                "description": "production"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/webhook-endpoints",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "url": "https://api.acme.com/ozav/webhooks",
                    "enabled_events": [
                        "customer.created",
                        "screening.completed",
                    ],
                    "description": "production",
                },
            )
            data = response.json()
    get:
      operationId: listWebhookEndpoints
      tags: [ Webhooks ]
      summary: List webhook endpoints
      description: Lists your webhook endpoints using the standard cursor pagination.
        Secrets are never returned.
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Webhook endpoints.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookEndpoint" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/webhook-endpoints", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/webhook-endpoints",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /webhook-endpoints/{webhook_endpoint_id}:
    parameters:
      - name: webhook_endpoint_id
        in: path
        required: true
        schema: { type: string }
        description: "Opaque webhook endpoint id, prefixed `ozav_wh_`."
    get:
      operationId: getWebhookEndpoint
      tags: [ Webhooks ]
      summary: Retrieve a webhook endpoint
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The webhook endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
    patch:
      operationId: updateWebhookEndpoint
      tags: [ Webhooks ]
      summary: Update a webhook endpoint
      description: Update any subset of url, enabled_events, status, description. The
        signing secret is unchanged (use rotate-secret).
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointUpdateRequest" }
            examples:
              trocar_eventos:
                summary: Subscribe to a different set of events
                value:
                  enabled_events: [ transfer.completed, transfer.failed, deposit.credited ]
              desligar:
                summary: Disable an endpoint without deleting it
                value:
                  status: disabled
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The updated webhook endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id} \
              -X PATCH \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "enabled_events": [
                  "transfer.completed",
                  "transfer.failed",
                  "deposit.credited"
                ]
              }'
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}", {
              method: "PATCH",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "enabled_events": [
                  "transfer.completed",
                  "transfer.failed",
                  "deposit.credited"
                ]
              }),
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.patch(
                "https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "enabled_events": [
                        "transfer.completed",
                        "transfer.failed",
                        "deposit.credited",
                    ],
                },
            )

            data = response.json()
    delete:
      operationId: deleteWebhookEndpoint
      tags: [ Webhooks ]
      summary: Delete a webhook endpoint
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:write ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, enum: [ webhook_endpoint ] }
                  deleted: { type: boolean, enum: [ true ] }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id} \
              -X DELETE \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}", {
              method: "DELETE",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.delete(
                "https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /webhook-endpoints/{webhook_endpoint_id}/rotate-secret:
    parameters:
      - name: webhook_endpoint_id
        in: path
        required: true
        schema: { type: string }
        description: "Opaque webhook endpoint id, prefixed `ozav_wh_`."
    post:
      operationId: rotateWebhookEndpointSecret
      tags: [ Webhooks ]
      summary: Rotate a webhook endpoint's signing secret
      description: |
        Generates a new signing secret and returns it once. The previous secret
        stops signing immediately, so update your verifier before rotating.
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:write ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The endpoint with its new `secret` (shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/rotate-secret \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/rotate-secret", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/rotate-secret",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /webhook-endpoints/{webhook_endpoint_id}/test:
    parameters:
      - name: webhook_endpoint_id
        in: path
        required: true
        schema: { type: string }
        description: "Opaque webhook endpoint id, prefixed `ozav_wh_`."
    post:
      operationId: testWebhookEndpoint
      tags: [ Webhooks ]
      summary: Send a test event
      description: |
        Queues a signed `webhook.test` event for this endpoint, even if
        `webhook.test` is not in `enabled_events`. The response is the queued
        delivery row; delivery itself is asynchronous and retried by OZAV.
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:write ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "200":
          description: The queued test delivery.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/test \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/test", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.post(
                "https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/test",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /webhook-endpoints/{webhook_endpoint_id}/deliveries:
    parameters:
      - name: webhook_endpoint_id
        in: path
        required: true
        schema: { type: string }
        description: "Opaque webhook endpoint id, prefixed `ozav_wh_`."
    get:
      operationId: listWebhookDeliveries
      tags: [ Webhooks ]
      summary: List webhook deliveries
      description: |
        Lists recent delivery attempts for one webhook endpoint. Response bodies
        are truncated previews; endpoint secrets, secret hashes, Vault refs, and
        internal ids are never returned.
      x-ozav-entitlement: any
      x-ozav-scopes: [ webhooks:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: Delivery attempts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookDelivery" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/deliveries \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/deliveries", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/webhook-endpoints/{webhook_endpoint_id}/deliveries",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /events:
    get:
      operationId: listEvents
      tags: [ Webhooks ]
      summary: List events
      description: |
        Lists your account's events, newest first - the pull side of the event
        log. Each item is the same canonical envelope a webhook delivers, so you
        can reconcile or backfill a missed delivery without a webhook endpoint.
        Client-scoped; keyset-paginated.
      x-ozav-entitlement: any
      x-ozav-scopes: [ events:read ]
      x-ozav-availability: live
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: A page of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookEvent" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/events \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await fetch("https://developers.ozav.io/v2/events",
            {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/events",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /events/{event_id}:
    parameters:
      - name: event_id
        in: path
        required: true
        schema: { type: string }
        description: "Opaque event id, prefixed `ozav_evt_`."
    get:
      operationId: getEvent
      tags: [ Webhooks ]
      summary: Retrieve an event
      description: |
        Fetches a single event by id - the same canonical envelope a webhook
        delivers.
      x-ozav-entitlement: any
      x-ozav-scopes: [ events:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The event.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEvent" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/events/{event_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/events/{event_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/events/{event_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()

  /yield/realized-gains:
    get:
      operationId: listRealizedGains
      tags: [ Yield ]
      summary: Realized gain per month
      description: |
        The gain already MATERIALIZED by redemption, grouped by month and by
        position. This is the input a tax computation consumes: it is not a tax
        filing, and the name says so on purpose.

        Two things it deliberately does NOT do:

        * **No fiat conversion.** Converting needs the rate of the day of each
          operation, and which rate source is valid for tax purposes is a
          compliance decision. A number invented here would look like a filing
          and not be one. `currency` is always `asset_minor_units`.
        * **No filing format.** What a tax authority requires is a separate
          decision from what the gain IS.

        Every amount is a decimal integer STRING in the asset smallest unit.
        Sending it as a JSON number would lose precision above 2^53, and this
        number ends up in a declaration.

        The competence month is the month of the BLOCK, never of when the
        movement was recorded. Those differ when ingestion lags, and on a month
        boundary the difference is the wrong filing period.

        Positions are computed separately: merging two would let a redemption in
        one consume the cost basis of the other, and both figures would be
        plausible and wrong.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ yield:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: Realized gain per month and position.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      currency:
                        type: string
                        enum: [ asset_minor_units ]
                        description: Always the asset smallest unit; no fiat conversion is applied.
                      note: { type: string }
                      months:
                        type: array
                        items:
                          type: object
                          properties:
                            month: { type: string, example: "2026-02" }
                            chain: { type: string }
                            vault_address: { type: string }
                            asset: { type: string }
                            realized_gain:
                              type: string
                              description: Decimal integer string; may be negative.
                              example: "200000"
                            remaining_cost_basis: { type: string, example: "0" }
                            remaining_shares: { type: string, example: "0" }
                            movements: { type: integer, example: 1 }
              examples:
                dois_meses:
                  summary: Deposit in January, redemption with gain in February
                  value:
                    data:
                      currency: asset_minor_units
                      note: realized gain in the asset minor unit; no fiat conversion is applied
                      months:
                        - month: "2026-01"
                          chain: base
                          asset: USDC
                          vault_address: "0x1234"
                          realized_gain: "0"
                          remaining_cost_basis: "1000000"
                          remaining_shares: "1000000"
                          movements: 1
                        - month: "2026-02"
                          chain: base
                          asset: USDC
                          vault_address: "0x1234"
                          realized_gain: "200000"
                          remaining_cost_basis: "0"
                          remaining_shares: "0"
                          movements: 1
        "503":
          description: The movement reader is not wired in this deployment.
          content:
            application/json:
              examples:
                indisponivel:
                  summary: Refusal, never an empty list
                  value:
                    error:
                      type: api_error
                      code: YIELD_MOVEMENTS_UNAVAILABLE
                      message: realized gains are unavailable in this deployment
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/realized-gains \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/realized-gains", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/yield/realized-gains",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /yield/strategies:
    get:
      operationId: listYieldStrategies
      tags: [ Yield ]
      summary: List yield strategies
      description: |
        Lists the yield strategies open to the client: the asset, the network
        and the indicative rate. Read-only, and the same catalogue for every
        client holding the entitlement.

        `apy_bps` is INDICATIVE and refreshed off-chain; it is `null` when no
        recent figure exists, which is not the same as zero.

        `realized_apy_7d` and `realized_apy_30d` are MEASURED from the on-chain
        share price we record hourly. They sit beside the indicative rate and
        never replace it: the indicative figure says what the protocol pays now,
        the realized figure says what actually accrued.

        When a realized figure cannot be stated, `known` is `false` and `reason`
        says why, rather than the field being null or zero:
          * `no_data`: no series for this strategy.
          * `window_not_covered`: the series began INSIDE the window, so a
            figure would be labelled for a period it did not measure.
          * `window_too_short`: under 12 hours between readings; annualising
            that multiplies noise by thousands.
          * `price_went_backwards`: the share price fell. That is an alarm in a
            lending vault, not a negative yield.
          * `unusable_price`: a reading could not be used.

        When `known` is `true`, `observed_hours` reports what was actually
        measured; the `7d`/`30d` in the field name is what was REQUESTED.

        Applying and redeeming are not exposed. Those move funds on-chain and
        remain gated.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ yield:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The strategies open to the client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        chain: { type: string }
                        asset: { type: string }
                        apy_bps: { type: [ integer, "null" ] }
                        realized_apy_7d: { $ref: "#/components/schemas/RealizedApy" }
                        realized_apy_30d: { $ref: "#/components/schemas/RealizedApy" }
                        risk: { $ref: "#/components/schemas/YieldRisk" }
              examples:
                one:
                  summary: One strategy, 7d measured and 30d not yet covered
                  value:
                    data:
                      - id: base-usdc-prime
                        chain: base
                        asset: USDC
                        apy_bps: 445
                        realized_apy_7d:
                          known: true
                          bps: 512
                          observed_hours: 167
                          points: 168
                        realized_apy_30d:
                          known: false
                          reason: window_not_covered
                        risk:
                          management: curated
                          curator_ref: curator-1
                          issuer_freeze: none
                          redemption_form: immediate
                          principal_guaranteed: false
                          smart_contract_risk: true
                          measured_at: "2026-08-06"
                queued:
                  summary: A strategy whose redemption is a request, and whose issuer holds a key
                  value:
                    data:
                      - id: solana-usd-treasury
                        chain: solana
                        asset: USDC
                        apy_bps: null
                        realized_apy_7d:
                          known: false
                          reason: no_data
                        realized_apy_30d:
                          known: false
                          reason: no_data
                        risk:
                          management: direct
                          curator_ref: null
                          issuer_freeze: unilateral_key
                          redemption_form: by_request
                          principal_guaranteed: false
                          smart_contract_risk: true
                          measured_at: "2026-08-09"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/strategies \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/strategies", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/yield/strategies",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /yield/wallets/{address}/positions:
    get:
      operationId: listYieldPositionsByAddress
      tags: [ Yield ]
      summary: List yield positions for a wallet you control
      description: |
        Lists the yield positions held by a wallet address you supply. Use this
        when the holder keeps their own key and is not under OZAV custody.

        The address is read from the path. That is safe and deliberate: an
        address balance is public data on the chain, readable by anyone without
        anyone's permission. The account making the call still comes from your
        API key and can never be supplied in a request, on this route or any
        other.

        The address is matched to the networks of its own family. An EVM address
        is not queried on Solana and the reverse, so a network that cannot apply
        never lands in `unreadable_chains` and never turns `complete` to false
        for a reason that is not real.

        **`cost_basis` is `null` here, and that is the honest answer.** The
        deposit did not pass through OZAV, so there is no book to read. Those
        positions report `accounting: "untracked"` with the reason attached.
        What the position is worth today is read from the chain and is exact.
      x-ozav-entitlement: self_custody
      x-ozav-scopes: [ yield:read ]
      x-ozav-availability: live
      parameters:
        - name: address
          in: path
          required: true
          schema: { type: string }
          description: |
            The wallet address. EVM (`0x` + 40 hex) or Solana (base58). An
            address matching neither is refused with `WALLET_ADDRESS_INVALID`
            rather than searched for, because "not found" and "mistyped" read
            the same and mean opposite things.
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The positions held by that address.
          content:
            application/json:
              schema:
                type: object
                properties:
                  positions:
                    type: array
                    items:
                      type: object
                      properties:
                        chain: { type: string }
                        asset: { type: string }
                        balance: { type: string }
                        apy_bps: { type: integer }
                        cost_basis:
                          type: [ string, "null" ]
                          description: |
                            Always `null` on this route. The deposit did not pass
                            through OZAV, so there is no book. `null` is the
                            absence of the number, never zero, which would be a
                            claim that nothing was paid.
                        unrealized: { type: [ string, "null" ] }
                        realized: { type: [ string, "null" ] }
                        accounting:
                          type: string
                          description: |
                            `untracked` on this route, with the reason attached.
                        redemption: { $ref: "#/components/schemas/YieldRedemption" }
                  unreadable_chains:
                    type: array
                    items: { type: string }
                  complete: { type: boolean }
              examples:
                one:
                  summary: A wallet OZAV has never seen, so no acquisition cost
                  value:
                    positions:
                      - chain: base
                        asset: USDC
                        balance: "42180550"
                        apy_bps: 445
                        cost_basis: null
                        unrealized: null
                        realized: null
                        accounting: untracked
                        redemption:
                          kind: available
                          available_to_withdraw: "42180550"
                    unreadable_chains: []
                    complete: true
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl https://developers.ozav.io/v2/yield/wallets/{address}/positions
            \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/yield/wallets/{address}/positions", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/yield/wallets/{address}/positions",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /yield/applications:
    post:
      operationId: createYieldApplication
      tags: [ Yield ]
      summary: Apply custodied balance to a yield strategy
      description: |
        Instructs OZAV to apply this client's custodied balance to a strategy.
        OZAV signs with its own key, under a two-party quorum.

        THIS IS THE OPPOSITE OF `/yield/deposits/build`. That route hands back an
        UNSIGNED transaction for a client that holds its own key. This one moves
        the balance OZAV custodies, on the client's instruction. The two carry
        different scopes for that reason: `yield:write` builds, `yield:apply`
        instructs.

        CLOSED PILOT. `yield:apply` cannot be self-served: `POST /api-keys`
        refuses to grant a scope the caller does not already hold, and no
        entitlement bootstraps this one. It is granted per account, by OZAV,
        while the pilot is closed. Asking for it on a key mint answers
        `SCOPE_NOT_HELD` (403), and that is the designed answer, not a defect.

        TWO SWITCHES BEHIND IT, both off by default. Even with the scope, the
        order is refused with `MANAGED_YIELD_NOT_ENABLED` until the surface is
        enabled for the environment, and with `MANAGED_YIELD_CLIENT_NOT_ENABLED`
        until it is enabled for the account. The two are separate codes because
        they call for opposite actions: wait for a launch, or ask to be enrolled.
        Both arrive as the application's terminal `refused` state with a
        `detail`, never as an error on this call, because this call only records
        the instruction.

        ANSWERS 202, NEVER 200. The signature needs a second approval that can
        wait on a person, so a synchronous answer would time out before there
        was one. The response carries an `id`; the state is read from
        `GET /yield/applications/{application_id}`. This is not a lesser
        contract, it is an honest one about what custody means.

        ONE APPLICATION IN FLIGHT per strategy. A second call while one is still
        `pending` or `running` answers 202 with the EXISTING application and
        `duplicate: true` rather than opening another: two in flight would race
        for the same free balance and one would revert on-chain, with gas paid.

        THE GATES ARE CHECKED WHEN IT RUNS, not here. This route verifies that
        the strategy exists and the amount is positive; authorisation and the
        exposure limits are enforced by the executor, and a refusal surfaces as
        `status: refused` with the cause in `detail`. Duplicating them here
        would create two answers to "may this apply?", and the looser one would
        win on the path nobody watches.

        AUTHORISATION IS NOT A CLIENT ACTION. Consent lives in the custody
        agreement; whether this client may have its balance applied is an OZAV
        operational control, not something the caller can turn on.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ yield:apply ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ strategy_id, amount ]
              properties:
                strategy_id:
                  type: string
                  description: The strategy to apply to, as listed by `GET /yield/strategies`.
                amount:
                  type: string
                  description: |
                    Amount in the asset's smallest unit, as a decimal integer
                    STRING. A JSON number would lose precision above 2^53.
            examples:
              aplicar:
                summary: Apply 100 USDC (smallest unit) to a strategy
                value:
                  strategy_id: base-usdc-prime
                  amount: "100000000"
      responses:
        '202':
          description: Accepted. The application is recorded; read its state by id.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/YieldApplication'
                  duplicate:
                    type: boolean
                    description: |
                      True when an application was already in flight for this
                      strategy and `data` is that one, not a new one. `data.amount`
                      is that order's amount and MAY DIFFER from the one just sent:
                      the request was accepted as a no-op, not as a new instruction.
        '400':
          $ref: '#/components/responses/BadRequest'
        "401": { $ref: "#/components/responses/Unauthorized" }
        '404':
          $ref: '#/components/responses/NotFound'
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/applications \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/applications", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/yield/applications",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "strategy_id": "base-usdc-prime",
                    "amount": "100000000",
                },
            )
            data = response.json()
  /yield/applications/{application_id}:
    get:
      operationId: getYieldApplication
      tags: [ Yield ]
      summary: Read the state of a yield application
      description: |
        The state of an application created by `POST /yield/applications`.

        `pending` means it is recorded and not yet executed; `running` means the
        executor took it; `applied` means it is confirmed on-chain; `refused`
        means a gate declined it and NO money moved; `failed` means it was
        attempted and did not complete. `detail` carries the cause for the last
        two, and `tx_hash` is present only when a transaction existed.

        AN APPLICATION BELONGING TO ANOTHER CLIENT ANSWERS 404, never 403. A 403
        would confirm the id exists, and that is information about someone
        else's account.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ yield:read ]
      x-ozav-availability: live
      parameters:
        - name: application_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The application.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/YieldApplication'
        "401": { $ref: "#/components/responses/Unauthorized" }
        # 🔴 404 TAMBEM PARA ORDEM DE OUTRO CLIENTE, e nao 403. Um 403 confirmaria que o id
        # existe, que ja e informacao sobre conta alheia. A consulta e escopada ao titular, entao
        # "nao e sua" e "nao existe" chegam aqui como a mesma coisa, de proposito.
        "404": { $ref: "#/components/responses/NotFound" }
      x-codeSamples:
        - lang: curl
          label: cURL
          source: >-
            curl
            https://developers.ozav.io/v2/yield/applications/{application_id} \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/yield/applications/{application_id}", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/yield/applications/{application_id}",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()
  /yield/positions:
    get:
      operationId: listYieldPositions
      tags: [ Yield ]
      summary: List yield positions
      description: |
        Lists the authenticated client's yield positions, with the amount
        applied, the acquisition cost and the accrued gain. Read-only.

        The owner is derived from the authenticated key and is never read from
        the request: a position can only ever be read by the client that holds
        it.

        `unreadable_chains` is reported separately and is not folded into the
        position list. An empty list with a non-empty `unreadable_chains` means
        a chain could not be read, which is not the same as holding nothing;
        check `complete` before treating the result as a full picture.

        `redemption` reports what can leave the position RIGHT NOW, which is a
        different question from `balance` (what the position is worth). The two
        diverge when the underlying market is fully utilised. It is discriminated
        by cause, because the causes call for opposite actions:
          * `available`: the amount in `available_to_withdraw` can be redeemed.
          * `held`: the balance is there and nothing can be redeemed right now
            (queue, pause or illiquidity). The funds exist and are still earning;
            this is never the same as holding nothing.
          * `unreadable`: the limit could not be read. `available_to_withdraw`
            is `null`, never `"0"`: zero would assert that nothing can leave.

        Applying and redeeming are not exposed. Those move funds on-chain and
        remain gated.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ yield:read ]
      x-ozav-availability: live
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The client's yield positions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  positions:
                    type: array
                    items:
                      type: object
                      properties:
                        chain: { type: string }
                        asset: { type: string }
                        balance: { type: string }
                        apy_bps: { type: integer }
                        cost_basis: { type: [ string, "null" ] }
                        unrealized: { type: [ string, "null" ] }
                        realized: { type: [ string, "null" ] }
                        accounting: { type: string }
                        redemption: { $ref: "#/components/schemas/YieldRedemption" }
                  unreadable_chains:
                    type: array
                    items: { type: string }
                  complete: { type: boolean }
              examples:
                one:
                  summary: One position, every chain read
                  value:
                    positions:
                      - chain: base
                        asset: USDC
                        balance: "42180550"
                        apy_bps: 445
                        cost_basis: "41000000"
                        unrealized: "1180550"
                        realized: "0"
                        accounting: complete
                        redemption:
                          kind: available
                          available_to_withdraw: "42180550"
                    unreadable_chains: []
                    complete: true
                held:
                  summary: The balance is there and cannot be redeemed right now
                  value:
                    positions:
                      - chain: base
                        asset: USDC
                        balance: "42180550"
                        apy_bps: 445
                        cost_basis: "41000000"
                        unrealized: "1180550"
                        realized: "0"
                        accounting: complete
                        redemption:
                          kind: held
                          available_to_withdraw: "0"
                    unreadable_chains: []
                    complete: true
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/positions \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/positions", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.get(
                "https://developers.ozav.io/v2/yield/positions",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )
            data = response.json()
  /yield/preview:
    post:
      operationId: previewYieldDeposit
      tags: [ Yield ]
      summary: Preview applying to a yield strategy
      description: |
        Prices an application before it is made: the venue cap, and the network
        cost of every on-chain step the operation requires.

        READ-ONLY. It is a POST because it carries a body (what to apply and how
        much), not because it writes. Nothing leaves any wallet, and the answer
        is returned immediately.

        The owner is derived from the authenticated key. The body carries WHAT
        and HOW MUCH, never WHOSE.

        There is no published minimum, and that is deliberate: a minimum is not a
        fact of documentation, it is a fact of quotation. It moves with the
        venue, the asset and the moment. Send an amount and read the typed
        refusal:
          * `AMOUNT_IN_TOO_HIGH` : above the cap the venue will currently accept,
            read live from the contract.
          * `AMOUNT_IN_TOO_LOW` : the network cost of the operation is not less
            than the amount being applied, so applying would destroy value. This
            floor is live, and rises on its own when the network gets expensive.

        `network_cost` sums EVERY step. Applying is normally two transactions
        (an allowance, then the deposit), and the second cannot be measured
        before the first exists on-chain. When any step cannot be measured the
        whole total is withheld rather than partially summed, because a cost that
        is quietly too low is worse than no cost at all: it is what the client
        decides on. `network_cost_unknown_reason` then names the cause.

        `network_cost` is what the blockchain network charges to transact. It is
        not a charge by OZAV.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ yield:read ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ strategy_id, amount ]
              properties:
                strategy_id:
                  type: string
                  description: The `id` from `GET /yield/strategies`.
                  example: base-usdc-prime
                amount:
                  type: string
                  description: |
                    Decimal integer string, in the asset's smallest unit. Never a
                    float: floats lose precision in money silently.
                  pattern: "^[0-9]{1,32}$"
                  example: "100000000"
            examples:
              prever:
                summary: Preview the yield on 100 USDC before committing
                value:
                  strategy_id: base-usdc-prime
                  amount: "100000000"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The application priced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  amount: { type: string }
                  network_cost:
                    oneOf: [ { $ref: "#/components/schemas/NetworkCost" }, { type: "null" } ]
                  network_cost_unknown_reason:
                    type: [ string, "null" ]
                    enum: [ gas_unknown, price_unknown, unusable_value, null ]
                    description: |
                      Why the cost is not stated, when it is not. `null` when it
                      is. A null cost with no reason would not distinguish "the
                      network charges nothing" from "it could not be measured".
              examples:
                measured:
                  summary: Both steps measured
                  value:
                    ok: true
                    amount: "100000000"
                    network_cost: { amount: "0.60", currency: USD }
                    network_cost_unknown_reason: null
                unmeasured:
                  summary: First application, so the deposit step cannot be estimated yet
                  value:
                    ok: true
                    amount: "100000000"
                    network_cost: null
                    network_cost_unknown_reason: gas_unknown
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/preview \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/preview", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/yield/preview",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "strategy_id": "base-usdc-prime",
                    "amount": "100000000",
                },
            )
            data = response.json()
  /yield/deposits/build:
    post:
      operationId: buildYieldDeposit
      tags: [ Yield ]
      summary: Build an unsigned yield application
      description: |
        Returns the ordered, UNSIGNED transactions that apply to a strategy, for
        the client's own wallet to sign and broadcast.

        SELF-CUSTODY ONLY. A client whose funds sit under managed custody does
        not hold a key, so unsigned transactions would be unusable to it, and
        OZAV does not sign on its behalf here. That request is refused with
        `CUSTODY_MODE_NOT_SELF_CUSTODY` rather than answered with something
        unsignable.

        BREAKING CHANGE, 2026-08-24. If `address` is supplied it MUST BE THE
        WALLET ALREADY REGISTERED for this client on that network. Any other
        address answers `WALLET_ADDRESS_NOT_REGISTERED` (403), even when the
        address is perfectly valid, and even though this call takes funds OUT.

        The reason is the same one that governs the deposit side, and it is not
        symmetry for its own sake: the shares being burned are held by the
        registered wallet. An unregistered address either holds nothing, in which
        case the transaction reverts after the client pays the gas, or holds a
        position OZAV never recorded, in which case the withdrawal would not
        reconcile against any book entry.

        One address per network. `POST /wallets` REPLACES the address registered
        for a network rather than adding to it, so a client that registers a
        second address on the same network can no longer build for the first.

        OZAV NEITHER SIGNS NOR BROADCASTS on this route. The response states so
        in `signed_by`, and `signer` reports the wallet the transactions were
        built for so it can be checked against the wallet about to sign, before
        signing rather than after.

        The transactions are ordered, and the order is the signing order: the
        deposit reverts without the allowance granted by the step before it.

        The vault address appears in `to`, and that is deliberate. Every other
        yield surface withholds it, because the holder never needs it; here the
        client signs a transaction TO that contract, and asking for a signature
        to a destination we decline to name would be worse than naming it. The
        protocol NAME is still withheld, on this route as on every other: nothing
        in the signature depends on it.

        Two gates, in series. While the environment gate is closed the route
        answers `YIELD_BUILD_NOT_ENABLED` (503). Once it is open, building is a
        closed pilot: an account that is not enrolled answers
        `YIELD_BUILD_NOT_IN_PILOT` (403). If enrolment cannot be read, the route
        answers `YIELD_PILOT_STATE_UNREADABLE` (503) and never a 403 it cannot
        justify, so retrying is the right response and asking for access is not.

        `address` MUST BE THE WALLET ALREADY REGISTERED for this client on that
        network. Any other address answers `WALLET_ADDRESS_NOT_REGISTERED` (403),
        even when the address is perfectly valid. Register the wallet first
        (`POST /wallets`), then build. Omitting `address` uses the registered
        wallet and is the simpler call.

        WHY THIS IS A REFUSAL AND NOT A CONVENIENCE. OZAV records what enters a
        vault by watching the addresses it knows. An address it has never been
        told about is not watched, so the position it holds would exist on-chain
        and not in the book, and a platform-wide exposure limit cannot be
        honoured from a total that is missing part of itself. Building for an
        unwatched address would make every later limit answer smaller than the
        truth.

        READS ARE UNAFFECTED. `GET /yield/wallets/{address}/positions` still
        accepts any address: reading public chain state creates no position that
        the book could miss.
      x-ozav-entitlement: self_custody
      x-ozav-scopes: [ yield:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ strategy_id, amount ]
              properties:
                strategy_id: { type: string, example: base-usdc-prime }
                amount:
                  type: string
                  pattern: "^[0-9]{1,32}$"
                  example: "100000000"
            examples:
              montar_deposito:
                summary: Build the unsigned deposit for 100 USDC
                value:
                  strategy_id: base-usdc-prime
                  amount: "100000000"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The unsigned transactions, in signing order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items:
                      type: object
                      properties:
                        kind: { type: string, enum: [ approve, deposit ] }
                        chain: { type: string }
                        to:
                          type: string
                          description: The contract to call. The token for `approve`, the vault for
                            `deposit`.
                        data: { type: string, description: Unsigned calldata. }
                        value:
                          type: string
                          description: Native value. Always "0" in ERC-20 flows.
                  signer:
                    type: string
                    description: |
                      The wallet these transactions were built for, derived from
                      the authenticated client. Check it against the wallet about
                      to sign.
                  signed_by:
                    type: string
                    enum: [ client ]
                    description: OZAV does not sign and does not broadcast here.
              examples:
                two_steps:
                  summary: Allowance then deposit
                  value:
                    transactions:
                      - kind: approve
                        chain: base
                        to: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
                        data: "0x095ea7b3"
                        value: "0"
                      - kind: deposit
                        chain: base
                        to: "0xeE8F4eC5672F09119b96Ab6fB59C27E1b7e44b61"
                        data: "0x6e553f65"
                        value: "0"
                    signer: "0x1111111111111111111111111111111111111111"
                    signed_by: client
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/deposits/build \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/deposits/build", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/yield/deposits/build",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "strategy_id": "base-usdc-prime",
                    "amount": "100000000",
                },
            )
            data = response.json()
  /yield/withdrawals/build:
    post:
      operationId: buildYieldWithdrawal
      tags: [ Yield ]
      summary: Build an unsigned yield withdrawal
      description: |
        Returns the UNSIGNED transaction that withdraws from a strategy, for the
        client's own wallet to sign and broadcast. The other half of the cycle:
        offering only the application would leave a client able to put funds in
        through this API and unable to take them out through it.

        ONE transaction, not two. Burning your own shares needs no allowance.

        Denominated in ASSETS, not shares. Honouring an asset-denominated request
        through a share-denominated call would mean converting and rounding, and
        the rounding would land against the holder.

        BUILT ONLY IF THE VENUE WILL RELEASE IT. How much can leave right now is
        read before the transaction is built. Without that read the client would
        sign, pay the gas, and watch the chain revert it, which is the worst place
        to find out. `WITHDRAW_EXCEEDS_AVAILABLE` says the balance is still there
        and still earning; `WITHDRAW_LIMIT_UNREADABLE` says the limit could not be
        read at all. Those call for opposite actions, so they are never merged.

        SELF-CUSTODY ONLY. A client whose funds sit under managed custody does
        not hold a key, so unsigned transactions would be unusable to it, and
        OZAV does not sign on its behalf here. That request is refused with
        `CUSTODY_MODE_NOT_SELF_CUSTODY` rather than answered with something
        unsignable.

        BREAKING CHANGE, 2026-08-24. If `address` is supplied it MUST BE THE
        WALLET ALREADY REGISTERED for this client on that network. Any other
        address answers `WALLET_ADDRESS_NOT_REGISTERED` (403), even when the
        address is perfectly valid, and even though this call takes funds OUT.

        The reason is the same one that governs the deposit side, and it is not
        symmetry for its own sake: the shares being burned are held by the
        registered wallet. An unregistered address either holds nothing, in which
        case the transaction reverts after the client pays the gas, or holds a
        position OZAV never recorded, in which case the withdrawal would not
        reconcile against any book entry.

        One address per network. `POST /wallets` REPLACES the address registered
        for a network rather than adding to it, so a client that registers a
        second address on the same network can no longer build for the first.

        OZAV NEITHER SIGNS NOR BROADCASTS on this route. The response states so
        in `signed_by`, and `signer` reports the wallet the transactions were
        built for so it can be checked against the wallet about to sign, before
        signing rather than after.

        The transactions are ordered, and the order is the signing order: the
        deposit reverts without the allowance granted by the step before it.

        The vault address appears in `to`, and that is deliberate. Every other
        yield surface withholds it, because the holder never needs it; here the
        client signs a transaction TO that contract, and asking for a signature
        to a destination we decline to name would be worse than naming it. The
        protocol NAME is still withheld, on this route as on every other: nothing
        in the signature depends on it.

        Two gates, in series. While the environment gate is closed the route
        answers `YIELD_BUILD_NOT_ENABLED` (503). Once it is open, building is a
        closed pilot: an account that is not enrolled answers
        `YIELD_BUILD_NOT_IN_PILOT` (403). If enrolment cannot be read, the route
        answers `YIELD_PILOT_STATE_UNREADABLE` (503) and never a 403 it cannot
        justify, so retrying is the right response and asking for access is not.
      x-ozav-entitlement: self_custody
      x-ozav-scopes: [ yield:write ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ strategy_id, amount ]
              properties:
                strategy_id: { type: string, example: base-usdc-prime }
                amount:
                  type: string
                  pattern: "^[0-9]{1,32}$"
                  example: "100000000"
            examples:
              montar_saque:
                summary: Build the unsigned withdrawal for 100 USDC
                value:
                  strategy_id: base-usdc-prime
                  amount: "100000000"
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The unsigned withdrawal transaction.
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items:
                      type: object
                      properties:
                        kind: { type: string, enum: [ withdraw ] }
                        chain: { type: string }
                        to:
                          type: string
                          description: The contract to call. The token for `approve`, the vault for
                            `deposit`.
                        data: { type: string, description: Unsigned calldata. }
                        value:
                          type: string
                          description: Native value. Always "0" in ERC-20 flows.
                  signer:
                    type: string
                    description: |
                      The wallet these transactions were built for, derived from
                      the authenticated client. Check it against the wallet about
                      to sign.
                  signed_by:
                    type: string
                    enum: [ client ]
                    description: OZAV does not sign and does not broadcast here.
              examples:
                one_step:
                  summary: A single withdrawal call
                  value:
                    transactions:
                      - kind: withdraw
                        chain: base
                        to: "0xeE8F4eC5672F09119b96Ab6fB59C27E1b7e44b61"
                        data: "0xb460af94"
                        value: "0"
                    signer: "0x1111111111111111111111111111111111111111"
                    signed_by: client
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/yield/withdrawals/build \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/yield/withdrawals/build", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "strategy_id": "base-usdc-prime",
                "amount": "100000000"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/yield/withdrawals/build",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "strategy_id": "base-usdc-prime",
                    "amount": "100000000",
                },
            )
            data = response.json()
  /network-transfers:
    post:
      operationId: createNetworkTransfer
      tags: [ Network Transfers ]
      summary: Move your own USDC to another network
      description: |
        Moves USDC you already hold from one network to another, in the same
        wallet. It is not a payment: origin and destination are the same holder.

        NOT CALLABLE TODAY, and the reason is structural rather than a missing
        feature. The write scope this route requires is absent from the only
        scope bootstrap, so no API key can hold it; and pricing is unconfigured,
        which refuses rather than charging zero. Both are opened by a decision.

        ACCEPTS AND RETURNS 202, NEVER 200. The transfer burns on the origin
        network under a two-party signing quorum, waits for attestation, and only
        then mints on the destination. That is minutes, and the middle can wait on
        a person. A synchronous answer would be a promise this cannot keep, so the
        route hands back an id and the state is read separately.

        ONE TRANSFER IN FLIGHT PER SOURCE NETWORK. Two transfers leaving the same
        network compete for the same free balance; one of them reverts on chain
        with gas already paid. A second request while one is in flight answers 202
        with the transfer already running and `duplicate: true` - not an error,
        because you asked for a transfer and there is one.

        THE HOLDER COMES FROM THE KEY, never from the body. Accepting a holder
        would let a caller move someone else's balance, and the burn is
        irreversible.

        The amount is a decimal integer string in the asset's smallest unit. A
        JSON number above 2^53 has already lost precision before it arrives, and
        the amount moved would stop being the amount asked for.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ network_transfers:write ]
      x-ozav-availability: coming_soon
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ source_chain, destination_chain, asset, amount ]
              additionalProperties: false
              properties:
                source_chain: { type: string, example: ethereum }
                destination_chain: { type: string, example: base }
                asset:
                  type: string
                  description: Only USDC crosses today.
                  example: USDC
                amount:
                  type: string
                  description: Decimal integer string, smallest unit of `asset`. Never a float.
                  # O codigo recusa zero (`NETWORK_TRANSFER_AMOUNT_INVALID`) e recusa acima do
                  # teto uint256 (`NETWORK_TRANSFER_AMOUNT_TOO_LARGE`). O padrao antigo
                  # (`^[0-9]{1,32}$`) permitia "0" e barrava valores que a API ACEITA: um
                  # gateway que validasse pelo spec recusaria pedido bom.
                  pattern: "^[1-9][0-9]{0,77}$"
                  example: "5000000"
            examples:
              ethereum_para_base:
                summary: Move 5 USDC from Ethereum to Base
                value:
                  source_chain: ethereum
                  destination_chain: base
                  asset: USDC
                  amount: "5000000"
      responses:
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            The key does not hold `network_transfers:write`. This is the primary
            gate and it is not self-service: the scope is outside the bootstrap,
            so `POST /api-keys` cannot grant it. OZAV grants it per account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "409":
          description: |
            `NETWORK_TRANSFER_PRICE_NOT_CONFIGURED` : no active pricing row applies
            to this account, so the transfer is refused rather than priced at zero.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "503":
          description: |
            `NETWORK_TRANSFER_PRICE_UNREADABLE`: pricing could not be read, so the
            transfer cannot be accepted. Retry shortly.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "202":
          description: |
            Accepted. Read the state with `GET /network-transfers/{transfer_id}`.
            `duplicate` is true when a transfer was already in flight for that
            source network; the transfer returned is that one.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/NetworkTransfer" }
                  duplicate: { type: boolean }
              examples:
                accepted:
                  summary: Accepted
                  value:
                    data:
                      id: 3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607
                      status: pending
                      source_chain: ethereum
                      destination_chain: base
                      asset: USDC
                      amount: "5000000"
                      spread_bps: null
                      detail: null
                      created_at: "2026-08-24T22:00:00.000Z"
                    duplicate: false
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/network-transfers \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "source_chain": "ethereum",
                "destination_chain": "base",
                "asset": "USDC",
                "amount": "5000000"
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/network-transfers", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "source_chain": "ethereum",
                "destination_chain": "base",
                "asset": "USDC",
                "amount": "5000000"
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/network-transfers",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "source_chain": "ethereum",
                    "destination_chain": "base",
                    "asset": "USDC",
                    "amount": "5000000",
                },
            )
            data = response.json()

  /network-transfers/{transfer_id}:
    get:
      operationId: getNetworkTransfer
      tags: [ Network Transfers ]
      summary: Read the state of a network transfer
      description: |
        Returns the state of one transfer.

        A transfer that belongs to another holder answers 404, not 403: a 403
        would confirm the id exists, and that is information about someone else's
        account.

        `in_progress` covers everything between leaving the origin network and
        arriving at the destination. While it is in progress, do not retry: a
        second request would move the amount a second time.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ network_transfers:read ]
      x-ozav-availability: coming_soon
      parameters:
        - name: transfer_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        # 🔴 O $ref COMPARTILHADO, e nao um bloco proprio. Escrito inline, o bloco nao declarava
        # `content`, entao o cliente GERADO tipava o corpo do 404 como AUSENTE (`content?: never`)
        #: mas o handler devolve um envelope de erro completo. Quem tipasse pelo `.d.ts` nao
        # conseguia ler `error.code`, que e `NETWORK_TRANSFER_NOT_FOUND` e esta no catalogo.
        # Era o unico dos 51 quatrocentos-e-quatro da spec fora do padrao.
        "404": { $ref: "#/components/responses/NotFound" }
        "200":
          description: The transfer state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/NetworkTransfer" }
              examples:
                in_progress:
                  summary: Left the origin network, not yet arrived
                  value:
                    data:
                      id: 3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607
                      status: in_progress
                      source_chain: ethereum
                      destination_chain: base
                      asset: USDC
                      amount: "5000000"
                      spread_bps: 15
                      detail: null
                      created_at: "2026-08-24T22:00:00.000Z"
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/network-transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1 \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef"
        - lang: JavaScript
          label: Node.js
          source: |-
            const response = await fetch("https://developers.ozav.io/v2/network-transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1", {
              method: "GET",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
              },
            });
            const data = await response.json();
        - lang: Python
          label: Python
          source: >-
            import requests


            response = requests.get(
                "https://developers.ozav.io/v2/network-transfers/ozav_tr_01J8Z9K2M3N4P5Q6R7S8T9U0V1",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                },
            )

            data = response.json()

  /swap/quote:
    post:
      operationId: quoteSwap
      tags: [ Swap ]
      summary: Price a same-network asset swap
      description: |
        Prices a swap between two assets on the same network, comparing the
        sources available and returning the best net rate.

        READ-ONLY. It is a POST because it carries a body (the pair, the amount,
        the tolerance), not because it writes. Nothing leaves any wallet.

        THE QUOTE DOES NOT EXECUTE, and `executable` says so rather than leaving
        it to be assumed. There is no route that accepts a quote: moving value
        needs an execution gate that no source currently passes. The absence of
        that route is the gate, not an omission.

        The wallet that receives the outgoing asset is derived from the
        authenticated client. The body carries the pair and the amount, never a
        destination: accepting one would let a caller price, and later execute, a
        swap whose proceeds land somewhere else.

        `slippage_bps` is required and has no silent default. A hidden default
        would decide, on the client's behalf, how much they accept losing.

        `min_amount_out` travels beside `amount_out`, never instead of it. Showing
        only the expected output would make the tolerance disappear from the
        answer, and the client would compare an optimistic figure against another
        provider's already-discounted one.

        NOT EVERY NETWORK CARRIES A PAIR. A swap needs both assets settled on
        the same network, and some networks carry only one of them. A pair no
        source serves answers a named error, never a zero or an empty quote, so a
        pair that cannot be served stays distinguishable from a bad price.

        `quotes_compared` and `sources_unavailable` are counts, never lists. That a
        comparison happened is worth knowing (the best of three is a different
        answer from the only one that replied), and the number says it without
        naming who.
      x-ozav-entitlement: custody
      x-ozav-scopes: [ swap:read ]
      x-ozav-availability: live
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ chain, asset_in, asset_out, amount_in, slippage_bps ]
              properties:
                chain: { type: string, example: ethereum }
                asset_in: { type: string, example: USDT }
                asset_out: { type: string, example: USDC }
                amount_in:
                  type: string
                  description: Decimal integer string, smallest unit of `asset_in`. Never a float.
                  pattern: "^[0-9]{1,32}$"
                  example: "100000000"
                slippage_bps:
                  type: integer
                  minimum: 0
                  description: Tolerance in basis points. 50 is 0.50%. Required.
                  example: 50
            examples:
              usdt_para_usdc:
                summary: Quote 100 USDT into USDC with 0.50% slippage tolerance
                value:
                  chain: ethereum
                  asset_in: USDT
                  asset_out: USDC
                  amount_in: "100000000"
                  slippage_bps: 50
      responses:
        "401": { $ref: "#/components/responses/Unauthorized" }
        "200":
          description: The swap priced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  quote:
                    type: object
                    properties:
                      chain: { type: string }
                      asset_in: { type: string }
                      asset_out: { type: string }
                      amount_in: { type: string }
                      amount_out: { type: string }
                      min_amount_out:
                        type: string
                        description: The floor an execution would have to honour, after tolerance.
                      expires_at: { type: string, format: date-time }
                      executable:
                        type: boolean
                        enum: [ false ]
                        description: Always false. This surface prices; it does not execute.
                  quotes_compared: { type: integer }
                  sources_unavailable:
                    type: integer
                    description: |
                      How many sources did not answer. Never silenced: a quote
                      chosen from one of three is a different answer from one
                      chosen from three of three.
              examples:
                best_of_two:
                  summary: Best of two sources
                  value:
                    quote:
                      chain: ethereum
                      asset_in: USDT
                      asset_out: USDC
                      amount_in: "100000000"
                      amount_out: "99910000"
                      min_amount_out: "99410450"
                      expires_at: "2026-08-08T23:00:00.000Z"
                      executable: false
                    quotes_compared: 1
                    sources_unavailable: 0
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl https://developers.ozav.io/v2/swap/quote \
              -X POST \
              -H "X-API-Key: ozav_sk_test_0123456789abcdef" \
              -H "Content-Type: application/json" \
              -d '{
                "chain": "ethereum",
                "asset_in": "USDT",
                "asset_out": "USDC",
                "amount_in": "100000000",
                "slippage_bps": 50
              }'
        - lang: JavaScript
          label: Node.js
          source: >-
            const response = await
            fetch("https://developers.ozav.io/v2/swap/quote", {
              method: "POST",
              headers: {
                "X-API-Key": "ozav_sk_test_0123456789abcdef",
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                "chain": "ethereum",
                "asset_in": "USDT",
                "asset_out": "USDC",
                "amount_in": "100000000",
                "slippage_bps": 50
              }),
            });

            const data = await response.json();
        - lang: Python
          label: Python
          source: |-
            import requests

            response = requests.post(
                "https://developers.ozav.io/v2/swap/quote",
                headers={
                    "X-API-Key": "ozav_sk_test_0123456789abcdef",
                    "Content-Type": "application/json",
                },
                json={
                    "chain": "ethereum",
                    "asset_in": "USDT",
                    "asset_out": "USDC",
                    "amount_in": "100000000",
                    "slippage_bps": 50,
                },
            )
            data = response.json()
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Secret key (ozav_sk_test_* or ozav_sk_live_*).
    BearerAuth:
      type: http
      scheme: bearer
      description: The same secret key presented as a bearer token.

  parameters:
    CustomerId:
      name: customer_id
      in: path
      required: true
      description: The opaque customer id (`ozav_cus_...`).
      schema: { type: string }
    DocumentId:
      name: document_id
      in: path
      required: true
      description: The opaque supporting-document id (`ozav_doc_...`).
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    StartingAfter:
      name: starting_after
      in: query
      description: Opaque cursor returned as `next_cursor` on the previous page.
      schema: { type: string }
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        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.
      schema:
        {
          type: string,
          minLength: 16,
          maxLength: 256,
          pattern: "^[A-Za-z0-9_-]{16,256}$"
        }
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Optional idempotency key (16-256 characters of `[A-Za-z0-9_-]`); same
        semantics as on money-moving operations.
      schema:
        {
          type: string,
          minLength: 16,
          maxLength: 256,
          pattern: "^[A-Za-z0-9_-]{16,256}$"
        }

  headers:
    IdempotentReplayed:
      description: >-
        Present and `true` when this response is a REPLAY of an earlier request
        that carried the same `Idempotency-Key` and the same body. The status
        code and body are byte-for-byte what the first call returned, and
        nothing happened a second time. Absent on a first execution. Use it to
        tell "I created it" from "it already existed", which the status code
        alone does not tell you.
      schema: { type: string, enum: [ "true" ] }
    XRequestId:
      description: >-
        Correlation id for this exact request. Set on EVERY response, success or
        error. On an ERROR it is also in the body (`error.request_id`); on a
        SUCCESS the body carries no id, so this header is the only place it
        exists. Log it. It is the first thing OZAV support asks for, and a
        generated client that ignores response headers throws it away.
      schema: { type: string, format: uuid }
  responses:
    BadRequest:
      description: The request is invalid.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    UnprocessableEntity:
      description: >
        The request is well-formed but cannot be fulfilled - e.g. a `rail` that
        no settlement-ready provider serves yet (see
        Rail.x-ozav-rail-availability).
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Unauthorized:
      description: Missing or invalid API key.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Forbidden:
      description: |
        The organization lacks the entitlement (code ENTITLEMENT_REQUIRED) or
        the key lacks the scope (code INSUFFICIENT_SCOPE). On money-moving
        requests (POST /transfers, POST /wallets/{wallet_id}/transfers) a
        compliance hold on the customer answers with code COMPLIANCE_HOLD: the
        hold is reviewed and lifted on OZAV's side; retrying does not clear it.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    NotFound:
      description: |-
        The resource does not exist, is not visible to the caller, or the id was
        malformed for this position (see "Resource ids" under Errors). These share
        one status on purpose; `code` and `message` tell them apart.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Conflict:
      description: Conflict (e.g. IDEMPOTENCY_KEY_REUSED).
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    IdempotencyProcessing:
      description: >
        Accepted - a concurrent request with the same `Idempotency-Key` is still
        executing; the body is `{"status": "processing"}`. Retry with the same
        key once the original completes: a completed request replays its stored
        response verbatim with the original status code and the
        `Idempotent-Replayed: true` header. This is NOT the resource - check the
        status code before deserializing.


        If this `202` repeats instead of resolving, the original request's
        outcome is UNKNOWN and no stored result is coming - the key stays
        claimed when a request fails after the resource may already have been
        written. Stop retrying, reconcile with a `GET` to see whether the
        resource exists, and use a NEW key if it does not.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema:
            type: object
            additionalProperties: false
            required: [ status ]
            properties:
              status: { type: string, const: processing }
          examples:
            in_flight:
              summary: Same Idempotency-Key still executing - retry with the same key
              value:
                status: processing
    NotImplementedYet:
      description: >
        The route exists and validated your request, but the capability behind
        it is not wired yet - a build-ahead seam, not a client error. Nothing
        was created. Do not retry on a loop; check `GET /capabilities` for
        go-live.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    DependencyUnavailable:
      description: >
        A dependency OZAV needs (storage, a resource lookup) could not be
        reached, so OZAV fails closed rather than guess. Safe to retry with the
        SAME `Idempotency-Key` where the operation accepts one.
      headers:
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    RateLimited:
      description: |
        Too many requests. Wait the number of seconds in the `Retry-After` response header
        before retrying; retrying sooner just consumes budget without being served.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer, minimum: 1 }
        X-Request-ID: { $ref: "#/components/headers/XRequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  schemas:
    NetworkTransfer:
      type: object
      description: |
        One network transfer of your own balance.

        `status` is deliberately smaller than the internal state machine. There is
        no "expired": internally that means the attestation window passed and will
        be requested again, and showing the word to a client would say the money
        was lost about a transfer that is going fine.
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [ pending, running, submitted, refused, failed, indeterminate ]
          description: |
            Six states, and the difference between them is money, so it is
            stated one by one. This mirrors `YieldApplication.status`.

            `pending` - accepted and queued. Nothing has been attempted yet.

            `running` - being attempted right now. Wait; do not order again.

            `submitted` - it left the source network and is on its way. This is
            NOT a failure and NOT a completion: the arrival is confirmed on the
            destination network, and this field does not yet report it.

            `refused` - a gate declined it and NO money left your balance.
            `detail` says which gate. Fixing what it names and ordering again is
            safe.

            `failed` - it was attempted and NOTHING left your balance, and that
            is known. Safe to order again.

            `indeterminate` - it stopped WITHOUT knowing whether the amount left
            your balance. DO NOT order it again: a second attempt may move the
            amount twice, and moving between networks cannot be undone. Support
            has been notified and a person confirms it on the network.
        source_chain: { type: string }
        destination_chain: { type: string }
        asset: { type: string }
        amount:
          type: string
          description: Decimal integer string, smallest unit of `asset`.
        spread_bps:
          type: [ integer, "null" ]
          description: |
            The spread RATE that applies to this transfer, in integer basis
            points, frozen when it was accepted. It is the rate, not an amount
            deducted: the transfer moves the full `amount` you requested. Null until then. The network cost is a separate line and is
            never folded into this.
        delivery:
          type: [ string, "null" ]
          enum: [ in_progress, completed, failed, null ]
          description: |
            Whether the amount ARRIVED on the destination network. This is a
            different question from `status`, and both matter.

            `status` is about the order: accepted, running, submitted, refused,
            failed, indeterminate. It stops at `submitted` once the amount
            leaves the source network, and stays there.

            `delivery` is about the money: `in_progress` while it is crossing,
            `completed` once it is credited on the destination network, and
            `failed` if the crossing ended without crediting.

            `null` means there is no crossing yet: the order has not left.
            It does NOT mean the amount failed to arrive.
        detail:
          type: [ string, "null" ]
          description: |
            Why, for `refused`, `failed` and `indeterminate`. Null otherwise.
            For `indeterminate` it carries the do-not-retry text.
        created_at: { type: string, format: date-time }
    YieldApplication:
      type: object
      description: |
        An instruction to apply custodied balance to a yield strategy. It is
        what the client ASKED FOR, not what the client HOLDS. The position
        actually held is read from `GET /yield/positions`, which is built from
        what happened on-chain. Confusing the two would make a refused
        instruction look like a position.
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [ pending, running, applied, refused, failed, indeterminate ]
          description: |
            Three of these are terminal and two of them are safe to retry. The
            difference is money, so it is stated field by field.

            `applied` - confirmed on-chain. `tx_hash` carries the transaction.

            `refused` - a gate declined it and NO money moved. `detail` says
            which gate. Fixing what it names and ordering again is safe.

            `failed` - it was attempted and NOTHING moved, and that is known:
            either the network rejected the transaction, or it never left. Safe
            to order again.

            `indeterminate` - it stopped WITHOUT knowing whether the money
            moved. Either an internal approval is still pending, or a
            transaction went out and was not yet recorded. DO NOT order it
            again: a second order can move the balance twice. While an
            application sits here, further orders for the same strategy answer
            202 with `duplicate: true` and this same application. It clears
            only after a person checks the chain. `tx_hash` is populated
            whenever a transaction is known, and it is the pointer to use.

            `pending` / `running` - queued, and being worked on.
        strategy_id: { type: string }
        amount:
          type: string
          description: Smallest unit, as a decimal integer string.
        detail:
          type: [ string, 'null' ]
          description: The cause, for `refused` and `failed`.
        tx_hash:
          type: [ string, 'null' ]
          description: Present only when a transaction existed; null for `refused`.
        created_at: { type: string, format: date-time }
    NetworkCost:
      type: object
      description: |
        What the blockchain network charges to carry out an operation. A pass
        through cost, not a charge by OZAV, and it is quoted on its own line.

        Reported at six decimal places when two would round a real cost to
        "0.00": a fraction of a cent on a cheap network is a real number, and
        rounding it to zero makes the same false claim that stating no cost
        would.
      required: [ amount, currency ]
      properties:
        amount:
          type: string
          description: Decimal string. Never a float.
          examples: [ "0.60", "0.003215" ]
        currency:
          type: string
          enum: [ USD ]
          description: The currency `amount` is quoted in. Always USD, including when the
            network charges in its own token - the token cost is converted so
            the line is comparable.
    YieldRedemption:
      type: object
      description: |
        What can leave a yield position right now, discriminated by CAUSE.

        `balance` says what the position is worth; this says what the venue will
        release. The two diverge when the underlying market is fully utilised,
        and the holder needs to know before requesting a withdrawal rather than
        at the point of refusal.

        The three causes call for opposite actions, so they are never collapsed:
        `held` means the funds are there and still earning (wait), `unreadable`
        means the limit could not be read (a defect to fix), and only
        `available` states a redeemable amount.
      required: [ kind, available_to_withdraw ]
      properties:
        kind:
          type: string
          enum: [ available, held, unreadable ]
          description: |
            WHY the position is in this state, and the three call for opposite actions. Branch on
            this before reading `available_to_withdraw`.

            `available` - a redeemable amount is stated. `held` - the funds are there and still
            earning, but the venue will not release them now; wait and re-read. `unreadable` - the
            limit could not be read, which is a defect on our side, not a statement about your
            funds; do not present it as zero.
        available_to_withdraw:
          type: [ string, "null" ]
          description: |
            Smallest-unit amount, as a string. `"0"` when `kind` is `held`, which
            is a measured zero. `null` when `kind` is `unreadable`, which is not
            a zero at all: it means the limit was never read.
      examples:
        - { kind: available, available_to_withdraw: "42180550" }
        - { kind: held, available_to_withdraw: "0" }
        - { kind: unreadable, available_to_withdraw: null }
    YieldRisk:
      type: object
      description: |
        What can go wrong with a strategy, stated as SHAPE rather than as names.

        OZAV never names the protocol, the curator, the issuer, or the contract
        address on a client surface: you contract with OZAV, not with the venue.
        That rule left a real gap, because without any of those you also could
        not tell two strategies apart on risk. This object closes it: it answers
        the questions that change a decision, and none that identify a party.

        `measured_at` is when these facts were read on-chain. A risk profile with
        no date ages without anyone noticing.
      required:
        - management
        - curator_ref
        - issuer_freeze
        - redemption_form
        - principal_guaranteed
        - smart_contract_risk
        - measured_at
      properties:
        management:
          type: string
          enum: [ direct, curated ]
          description: |
            Whether a team selects the underlying markets (`curated`) or nothing
            sits between the holder and the market (`direct`).
        curator_ref:
          type: [ string, "null" ]
          description: |
            An OPAQUE, stable label for the curator; `null` when `management` is
            `direct`. It is not a name and never becomes one. It answers exactly
            one question: do two strategies share the team that decides? That is
            the concentration question, and you can answer it without knowing who
            the team is. Two strategies with the same value concentrate the same
            decision, however different their networks look.
        issuer_freeze:
          type: string
          enum: [ none, contract_rule, unilateral_key ]
          description: |
            Whether the instrument's issuer can freeze the position, and how.
            `none`: no such authority exists. `contract_rule`: it exists and runs
            through public, auditable logic on-chain. `unilateral_key`: it exists
            as a KEY, and the issuer can freeze any account on its own.

            The SAME instrument can carry a different form on different networks.
            When it does, the two are listed as separate strategies and this field
            is what tells them apart.
        redemption_form:
          type: string
          enum: [ immediate, by_request ]
          description: |
            The form of redemption the instrument supports by design.
            `immediate` redeems against the contract, up to what it releases.
            `by_request` is a request to the issuer, with its own turnaround, and
            never settles on the spot.

            This is NOT today's liquidity. How much can leave right now is on the
            position, read live from the chain; a calm day never turns
            `by_request` into `immediate`.
        principal_guaranteed:
          type: boolean
          enum: [ false ]
          description: |
            Always `false`. OZAV does not promise, guarantee, or indicate a
            return, and does not repurchase or restore principal. The result is
            whatever the protocol produces, positive or negative.
        smart_contract_risk:
          type: boolean
          enum: [ true ]
          description: |
            Always `true`. The balance sits in a contract, and a contract can
            fail or be exploited. Admission checks reduce this risk and do not
            remove it.
        measured_at:
          type: string
          format: date
          description: When these facts were read on-chain, `YYYY-MM-DD`.

    RealizedApy:
      type: object
      description: |
        A MEASURED rate, derived from the on-chain share price recorded hourly.
        Distinct from the indicative rate, which is refreshed off-chain and
        states what the venue pays now. Both are published; neither replaces the
        other.

        When the rate cannot be stated, `known` is `false` and `reason` names the
        cause instead of the field being null or zero.
      required: [ known ]
      properties:
        known:
          type: boolean
          description: Whether a rate could be measured at all. Branch on this FIRST -
            when it is `false` there is no `bps` and `reason` names the cause.
            An unknown rate is not zero.
        bps:
          type: integer
          description: Present only when `known` is true.
        observed_hours:
          type: integer
          description: |
            How much time the readings actually span. Present only when `known`
            is true. The `7d`/`30d` in the field name is what was REQUESTED; this
            is what was measured.
        points:
          type: integer
          description: Readings used. Present only when `known` is true.
        reason:
          type: string
          enum:
            - no_data
            - window_not_covered
            - window_too_short
            - unusable_price
            - price_went_backwards
          description: Present only when `known` is false.
      examples:
        - { known: true, bps: 512, observed_hours: 167, points: 168 }
        - { known: false, reason: window_not_covered }
    DecimalAmount:
      type: string
      description: |
        Decimal string at accounting precision - up to 6 fractional digits.
        Never a float. The value must be greater than zero, with no leading
        zeros in the integer part (a bare `0` integer part is allowed for
        sub-unit values such as "0.50") and at most 15 integer digits.
      pattern: "^(0|[1-9]\\d{0,14})(\\.\\d{1,6})?$"
      examples: [ "1250.00", "0.50" ]
    TransferDecimalAmount:
      type: string
      description: |
        Decimal string accepted on transfer creation - up to 8 fractional
        digits. Never a float.
      pattern: "^\\d+(\\.\\d{1,8})?$"
      examples: [ "1250.00", "0.12345678" ]
    Currency:
      type: string
      description: >
        Uppercase ISO 4217 code or stablecoin symbol (e.g. BRL, USD, EUR, MXN,
        USDC, USDT). Uppercase is the canonical form: values are accepted
        case-insensitively on input but are always uppercase in responses.
      pattern: "^[A-Z]{3,6}$"
    Transaction:
      type: object
      description: >-
        Amounts use the SAME scale for both `type`s: a decimal string at the
        currency's human precision. A deposit is stored on-chain as an integer
        in the asset's smallest unit and is converted here, so `15.750742` never
        appears as `15750742` next to a transfer's `97.117228`. One movement in
        the unified feed. `type` tells you which resource it came from, and `id`
        is that resource's id - fetch the full record with it.
      required: [ object, id, type, status, amount, currency, created_at ]
      properties:
        object:
          type: string
          const: transaction
          description: Resource type discriminator for the envelope; always the literal
            `transaction`. What KIND of movement it is lives in `type`.
        id:
          type: string
          description: >-
            The underlying resource's id. For `transfer` it is the `ozav_tr_`
            id; for `deposit` it is the custody deposit id. Opaque either way -
            store it and pass it back, never parse it.
        type:
          type: string
          enum: [ transfer, deposit ]
          description: >-
            `transfer` - a movement you created through this API. `deposit` - an
            on-chain credit observed into custody.
        status:
          type: string
          description: >-
            The underlying resource's status, passed through unchanged. Read it
            together with `type`: the vocabularies are per-resource, so a
            `deposit` never carries a transfer status and the reverse.
        amount: { $ref: "#/components/schemas/DecimalAmount" }
        currency: { $ref: "#/components/schemas/Currency" }
        created_at:
          type: string
          format: date-time
          description: When the movement was recorded (RFC 3339, UTC). This is the field
            the feed is ordered by.
        customer_id:
          type: string
          description: Present when the movement belongs to a customer of yours.
        network:
          type: string
          description: Present on `deposit`; the network the credit arrived on.
    Rail:
      type: string
      description: |
        Canonical payment-rail vocabulary. Only rails backed by a
        settlement-ready provider are routable today (see the machine-readable
        `x-ozav-rail-availability`); a preview rail is defined for
        forward-compatibility, but registering a payout destination on one
        returns `422` until a settlement-ready provider serves that corridor.

        | Rail | Corridor | Availability |
        |---|---|---|
        | `pix` | BRL - Brazil instant payments | available |
        | `ach` | USD - US ACH | not offered |
        | `fedwire` | USD - US wire | sandbox only |
        | `sepa` | EUR - SEPA credit transfer | live (behind enablement) |
        | `spei` | MXN - Mexico SPEI | sandbox only |
        | `swift` | Cross-border SWIFT | live (behind enablement) |
        | `faster_payments` | GBP - UK Faster Payments | live (behind enablement) |
        | `co_transfer` | COP - Colombia local transfer | sandbox only |

        **`not offered`** means OZAV does not serve that corridor: the value stays
        in the enum so records that already carry it keep reading, and any request
        for it is refused with `ACCOUNT_RAIL_NOT_AVAILABLE` in every environment,
        sandbox included.

        **`live (behind enablement)`** means the corridor is served in production,
        but not to everyone: opening an account still requires that OZAV has
        enabled the specific customer for foreign-currency named accounts. Until
        then a `live` request is refused with a named error that says which step
        is missing - not with `ACCOUNT_RAIL_NOT_AVAILABLE`, which would send you
        looking at the corridor instead of the customer.

        **`sandbox only` is a real, usable state, and it is not "coming soon".**
        The corridor is fully exercisable with a `test` key: you can open the
        account, read its receiving instructions and build against the exact
        response shape production will return. What it does not have is a
        settlement provider behind it, so a `live` key is refused with
        `ACCOUNT_RAIL_NOT_AVAILABLE` rather than opening something that cannot
        receive. Build against it in sandbox; the request you write does not
        change when the corridor goes live.
      enum: [ pix, ach, fedwire, sepa, spei, swift, faster_payments, co_transfer ]
      # Derivado de ACCOUNT_RAIL_MATRIX (`_shared/v2/account-shape.ts`) e conferido por
      # `scripts/check-v2-account-rail-parity.mjs`. Vocabulario UNICO com o codigo, de proposito:
      # traduzir para um segundo conjunto de rotulos foi exatamente como `sepa` e `faster_payments`
      # acabaram anunciados como coisas diferentes tendo a MESMA disponibilidade.
      # 🔴 sepa, swift e faster_payments passaram a `live_gated` em 04-set-2026, por decisao do CEO.
      # O rotulo por trilho e o MAIS CAPAZ entre as combinacoes (ver o gate de paridade): `swift`
      # aparece em USD/US/swift, que segue fechado, e em USD/GB/swift, que abriu; a combinacao
      # exata que um cliente pode pedir esta em `x-ozav-account-rail-availability`, acima.
      x-ozav-rail-availability:
        pix: sandbox_active
        ach: coming_soon
        fedwire: sandbox_only
        sepa: live_gated
        spei: sandbox_only
        swift: live_gated
        faster_payments: live_gated
        co_transfer: sandbox_only
    Network:
      type: string
      description: |
        Public v2 blockchain network vocabulary for wallet balances and crypto
        transfer legs. This enum is the wire contract today and intentionally
        lists only networks accepted by public v2 fields now. The custody stack
        can have additional active networks before those networks are accepted
        by every public API flow; see `x-ozav-network-matrix` for that explicit
        split.
      enum:
        [
          ethereum,
          base,
          optimism,
          polygon,
          avalanche_c_chain,
          worldchain,
          unichain,
          linea,
          sonic,
          sei,
          celo,
          zksync_era,
          solana,
          tron,
          stellar
        ]
      x-ozav-network-availability:
        # `available`      entregue por padrao a todo cliente.
        # `opt_in`         a saida esta provada, mas a rede so aparece para o cliente cujo
        #                  `caas_clients.allowed_chains` a inclui. Abertura deliberada, por cliente.
        # `deposit_paused` a OZAV NAO entrega endereco de deposito nesta rede, para ninguem. Pausada
        #                  em 21-ago-2026 por nao haver par de saida habilitado: o cliente
        #                  depositaria e nao conseguiria sacar. A rede continua no enum, entao o
        #                  cliente que gera tipos a partir da spec nao quebra.
        #
        # `available` + `opt_in` espelham OPENABLE_DEPOSIT_NETWORKS, e `available` sozinho espelha
        # DEFAULT_OFFERED_DEPOSIT_NETWORKS, ambos em v2-wallets/deposit-addresses.ts.
        # `check-v2-network-parity.mjs` impede os dois lados de divergirem.
        ethereum: available
        base: deposit_paused
        optimism: deposit_paused
        polygon: opt_in
        avalanche_c_chain: deposit_paused
        worldchain: deposit_paused
        unichain: deposit_paused
        linea: deposit_paused
        sonic: deposit_paused
        sei: deposit_paused
        celo: deposit_paused
        zksync_era: deposit_paused
        solana: available
        tron: available
        stellar: opt_in
      x-ozav-network-matrix:
        # `native_cross_network` says the asset moves between networks by native burn and mint:
        # no pool, no wrapped token, no third-party custodian in the middle, and therefore no
        # slippage. It is a strong product claim, so it is not hand-written:
        # check-v2-network-parity.mjs verifies every row against the runtime and fails in BOTH
        # directions. Claiming it where the network does not support it would promise 1:1 on a
        # route that needs an intermediary, which carries a different class of risk; denying it
        # where it holds would hide the better route from the client.
        #
        # Moving value across networks is not exposed yet. This says WHERE a native move is
        # possible, not that one can be requested today.
        ethereum:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC, USDT ]
          verified_assets: [ USDC, USDT ]
          native_cross_network: true
        base:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: true
        optimism:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: true
        polygon:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC, USDT ]
          verified_assets: [ USDC, USDT ]
          native_cross_network: true
        avalanche_c_chain:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC, USDT ]
          verified_assets: [ USDC, USDT ]
          native_cross_network: true
        worldchain:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: true
        unichain:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        linea:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        sonic:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        sei:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        celo:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        zksync_era:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        solana:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC, USDT ]
          verified_assets: [ USDC, USDT ]
          native_cross_network: true
        tron:
          public_v2: accepted
          custody_stack: active
          assets: [ USDT ]
          verified_assets: [ USDT ]
          native_cross_network: false
        stellar:
          public_v2: accepted
          custody_stack: active
          assets: [ USDC ]
          verified_assets: [ USDC ]
          native_cross_network: false
        tempo:
          public_v2: not_accepted
          custody_stack: active
          assets: [ USDT ]
          verified_assets: [ USDT ]
          native_cross_network: false
        arbitrum:
          public_v2: not_accepted
          custody_stack: active
          assets: [ USDC, USDT ]
          verified_assets: [ USDC, USDT ]
          native_cross_network: true
        aptos:
          public_v2: not_accepted
          custody_stack: deferred
          assets: []
          verified_assets: []
          native_cross_network: false
        sui:
          public_v2: not_accepted
          custody_stack: deferred
          assets: []
          verified_assets: []
          native_cross_network: false
    Metadata:
      type: object
      description: Free-form string map (max 50 keys).
      additionalProperties: { type: string }
    MonetaryAmount:
      type: object
      description: An amount paired with its currency, at 6-fractional-digit
        accounting precision.
      required: [ amount, currency ]
      properties:
        amount: { $ref: "#/components/schemas/DecimalAmount" }
        currency: { $ref: "#/components/schemas/Currency" }
    TransferMonetaryAmount:
      type: object
      required: [ amount, currency ]
      description: Amount block accepted on transfer creation (8-fractional-digit
        precision).
      properties:
        amount: { $ref: "#/components/schemas/TransferDecimalAmount" }
        currency: { $ref: "#/components/schemas/Currency" }
    Address:
      type: object
      description: >-
        A postal address. `line1`, `city`, and `country` are required; the rest
        are optional. Jurisdictions that structure the address (Brazil among
        them) need it broken out - provide the discrete `number`,
        `neighborhood`, and (where relevant) `complement` fields, with `line1`
        then holding the street name alone. Which of these are required to
        onboard depends on jurisdiction and is reported in
        `Customer.requirements.missing_fields` (a Brazilian customer needs
        `line1`, `number`, `neighborhood`, `city`, `state`, and `postal_code`).
      required: [ line1, city, country ]
      properties:
        line1:
          {
            type: string,
            description: "Street. Where an address is not structured into discrete
              components, the building number may be included here; when the
              `number` field is provided, `line1` is the street name alone."
          }
        line2:
          {
            type: string,
            description: "Second address line - unit or suite. Optional. See also
              `complement`."
          }
        number:
          {
            type: string,
            description: "Building / house number, kept separate from the street. Optional
              in general; required for Brazilian onboarding."
          }
        neighborhood:
          {
            type: string,
            description: "Neighborhood or district (e.g. Brazilian `bairro`). Optional in
              general; required for Brazilian onboarding."
          }
        complement:
          {
            type: string,
            description: "Address complement - apartment, block, or reference (e.g.
              Brazilian `complemento`). Optional."
          }
        city: { type: string, description: "City or municipality." }
        state:
          {
            type: string,
            description: "State or province - an ISO 3166-2 subdivision code where
              applicable."
          }
        postal_code: { type: string, description: "Postal or ZIP code." }
        country:
          {
            type: string,
            description: "Country as an ISO 3166-1 alpha-2 code (e.g. BR, US).",
            pattern: "^[A-Z]{2}$"
          }
    Phone:
      type: object
      description: >-
        A contact phone split into an international dialing code and the
        national number, so onboarding requirements that need the phone's
        country (Brazil among them) can read it without parsing an E.164 string.
        Both parts are required when a phone is supplied.
      required: [ country_code, number ]
      properties:
        country_code:
          {
            type: string,
            description: "International dialing code, e.g. `+55` or `55`.",
            pattern: "^\\+?\\d{1,4}$"
          }
        number: { type: string, description: "National phone number." }
    HolderTaxId:
      type: object
      additionalProperties: false
      required: [ country, type, value ]
      description: |
        Tax / registration identifier of the **account holder**, who may be a person or a company.

        The `type` enum is the union of the personal (`cpf`, `ssn`, `nif`, `passport`) and company
        (`cnpj`, `ein`, `company_number`) identifiers, because a payout destination can be held by
        either. Use `other` only when none applies - it is the one value that carries no meaning and
        gets no validation.

        `cpf` and `cnpj` are checked against their own check digits and rejected with `400` when the
        number does not add up.
      properties:
        country:
          type: string
          description: Issuing country as an ISO 3166-1 alpha-2 code.
          pattern: "^[A-Z]{2}$"
        type:
          type: string
          description: |
            The kind of identifier carried in `value`:
            - `cpf` - Brazilian individual taxpayer registry. Check digits are verified.
            - `cnpj` - Brazilian company registry. Check digits are verified.
            - `ssn` - US Social Security Number.
            - `ein` - US Employer Identification Number.
            - `nif` - Iberian/European tax identification number.
            - `company_number` - company registry number outside Brazil/US.
            - `passport` - passport number.
            - `other` - any identifier not covered above. Carries no meaning and gets no validation.
          enum: [ cpf, cnpj, ssn, ein, nif, company_number, passport, other ]
        value:
          type: string
          description: The identifier itself. Punctuation is accepted and ignored.
    TaxId:
      type: object
      description: A national tax or personal identifier for an individual.
      required: [ country, type, value ]
      properties:
        country:
          {
            type: string,
            description: "Issuing country as an ISO 3166-1 alpha-2 code.",
            pattern: "^[A-Z]{2}$"
          }
        type:
          type: string
          description: |
            The kind of identifier carried in `value`:
            - `cpf` - Brazilian individual taxpayer registry (Cadastro de Pessoas Físicas).
            - `ssn` - US Social Security Number.
            - `nif` - Iberian/European tax identification number (Número de Identificação Fiscal).
            - `passport` - passport number.
            - `other` - any identifier not covered above.
          enum: [ cpf, ssn, nif, passport, other ]
        value:
          {
            type: string,
            description: "The identifier itself, as issued. Formatting characters are not
              required."
          }
    Registration:
      type: object
      description: A company registration or incorporation identifier for a business.
      required: [ country, type, value ]
      properties:
        country:
          {
            type: string,
            description: "Country of registration as an ISO 3166-1 alpha-2 code.",
            pattern: "^[A-Z]{2}$"
          }
        type:
          type: string
          description: |
            The kind of registration identifier carried in `value`:
            - `cnpj` - Brazilian company registry (Cadastro Nacional da Pessoa Jurídica).
            - `ein` - US Employer Identification Number.
            - `nif` - Iberian tax/registry number (Número de Identificação Fiscal).
            - `company_number` - a national company/registry number (e.g. Companies House).
            - `other` - any registration identifier not covered above.
          enum: [ cnpj, ein, nif, company_number, other ]
        value: { type: string, description: "The registration number itself, as issued." }
    CapabilityKind:
      type: string
      description: |
        Canonical OZAV verification / screening capability taxonomy - the checks
        you can request for a customer (`POST /verifications`) and the keys of a
        customer's `verification.checks` map.

        Identity / KYC (individuals):
        - `kyc_document` - government ID document capture and authentication.
        - `liveness` - confirms the person is physically present (anti-spoofing).
        - `face_match` - the selfie matches the ID document portrait.
        - `proof_of_address` - a document evidencing residential address.
        - `age_estimation` - estimate that the person is above an age threshold.

        Business / KYB (companies):
        - `kyb` - business registration and standing verification.
        - `ubo` - ultimate-beneficial-owner identification and checks.

        AML (name-based):
        - `aml_name_screening` - point-in-time designated-list name screening.
        - `aml_ongoing_monitoring` - continuous re-screening for new matches.

        Transaction / on-chain:
        - `wallet_screening` - blockchain-address risk screening.
        - `travel_rule` - originator/beneficiary information exchange for crypto sends.

        Contactability:
        - `email_verify` - the email address is reachable and controlled by the customer.
        - `phone_verify` - the phone number is reachable and controlled by the customer.

        Financial:
        - `economic_capacity` - assessment of the customer's economic capacity (declared/evidenced financial standing) used to justify transaction limits.
      enum:
        [
          kyc_document,
          liveness,
          face_match,
          proof_of_address,
          age_estimation,
          kyb,
          ubo,
          aml_name_screening,
          aml_ongoing_monitoring,
          wallet_screening,
          travel_rule,
          email_verify,
          phone_verify,
          economic_capacity
        ]
    CheckOutcome:
      type: string
      description: |
        Outcome of a single verification check.
        - `not_started` - the check has not begun.
        - `pending` - the check is in progress; no outcome yet.
        - `pass` - the check succeeded.
        - `fail` - the check did not succeed.
        - `review` - inconclusive; queued for manual review (never silently passed).
      enum: [ pass, fail, review, pending, not_started ]
    CheckResult:
      type: object
      description: The result of a single verification check.
      properties:
        outcome: { $ref: "#/components/schemas/CheckOutcome" }
        verified_at:
          type: string
          format: date-time
          description: When the check reached its outcome (RFC 3339 / ISO 8601, UTC).
            Present once the check has completed.
    CustomerStatus:
      type: string
      description: |
        Onboarding / KYC lifecycle state of a customer.

        | Value | Meaning |
        |---|---|
        | `pending` | Created; verification has not completed. The customer cannot transact yet. |
        | `in_review` | Checks were submitted and are under (automated or manual) review. |
        | `approved` | Verification passed. The customer may transact once OZAV has everything a payout will need; see the note below. |
        | `rejected` | Verification failed; the customer cannot be onboarded. |
        | `suspended` | A previously active customer has been blocked by a compliance or operational action. |

        **`approved` is about the checks, not about being able to operate.**
        OZAV activates a customer only when it also holds everything a payout
        will need: the holder's name, a valid tax document for the country
        declared on it, and an address. A Brazilian company with a valid CNPJ
        is exempt from the address, because the public registry supplies it.

        A customer can therefore sit at `approved` and still be refused, with
        `422` on `POST /accounts` and on the money paths. That is deliberate:
        before, such a customer entered and only discovered the gap at payout
        time, with funds already committed.

        The request body cannot resolve it. Send the missing field through the
        customer update path, or ask OZAV: the blocked queue names each
        customer and the exact field it is waiting on.
      enum: [ pending, in_review, approved, rejected, suspended ]
    CustomerIndividual:
      type: object
      description: >-
        Identity attributes for an `individual` customer or associated person.
        Every field except `legal_name` is optional at creation so
        multi-jurisdiction and incremental onboarding are supported - but
        onboarding a customer to completion needs more. What is still owed is
        reported per jurisdiction in `Customer.requirements.missing_fields`; for
        a Brazilian individual that is `mother_name`, `date_of_birth`, `phone`,
        `email`, a full structured `residential_address`, and `tax_id`.
      required: [ legal_name ]
      properties:
        legal_name:
          {
            type: string,
            description: "Full legal name as it appears on the identity document."
          }
        mother_name:
          {
            type: string,
            description: "Mother's full name. Required for Brazilian onboarding; optional at
              creation."
          }
        date_of_birth:
          {
            type: string,
            format: date,
            description: "Date of birth (ISO 8601 date, YYYY-MM-DD). Validated as a real
              calendar date, not in the future, at least 18 years ago and not
              more than 120 - a structurally valid string that is none of those
              is rejected at create, not accepted and passed on."
          }
        nationality:
          type: string
          pattern: "^[A-Z]{2}$"
          description: >-
            Nationality as an ISO 3166-1 alpha-2 country code. It feeds risk
            assessment, and it does NOT change which fields are required: the
            required-field set is driven by the country on `tax_id` (for an
            individual) or `registration` (for a business), not by nationality
            and not by the currency of any account you later open. It does not
            change WHICH OTHER fields are required (the tax-id country drives
            that branch), but `nationality` is itself reported in
            `requirements.missing_fields` until you supply it.
        phone:
          allOf:
            - $ref: "#/components/schemas/Phone"
          description: >-
            Structured contact phone (dialing code + number), distinct from the
            loose top-level `Customer.phone` string. Required for Brazilian
            onboarding, which needs the phone's country explicitly.
        is_pep:
          {
            type: boolean,
            description: "Politically-exposed-person self-declaration. Optional at creation;
              treated as `false` when omitted."
          }
        tax_id: { $ref: "#/components/schemas/TaxId" }
        residential_address: { $ref: "#/components/schemas/Address" }
        occupation: { type: string, description: "The person's profession or occupation." }
        source_of_funds:
          {
            type: string,
            description: "Declared origin of the funds the person will move (e.g. salary,
              savings, sale of property). Reported in
              `requirements.missing_fields` until supplied."
          }
        monthly_income_usd:
          {
            type: number,
            minimum: 0,
            description: "Self-declared monthly income in USD (amounts are USD-denominated).
              This is the CAPACITY the expected monthly volume is measured
              against: without it the economic-capacity assessment has nothing
              to compare and returns no verdict. `0` is a valid answer (declared
              no income) and is not treated as absent. Reported in
              `requirements.missing_fields` until supplied."
          }
        expected_monthly_volume_usd:
          {
            type: number,
            minimum: 0,
            description: "Expected monthly transaction movement in USD. This is the amount
              the economic-capacity assessment MEASURES; what it measures
              against is the declared capacity (`monthly_income_usd` for an
              individual, `annual_revenue_usd` for a business). Supplying this
              alone yields no assessment. Reported in
              `requirements.missing_fields` until supplied."
          }
        account_purpose:
          {
            type: string,
            description: "Intended use of the account / purpose of the relationship."
          }
    CustomerBusiness:
      type: object
      description: >-
        Identity attributes for a `business` customer. Only `legal_name` and
        `registration` are required at creation; onboarding a business to
        completion needs more, reported per jurisdiction in
        `Customer.requirements.missing_fields`. For a Brazilian business that is
        `phone`, a business email (the customer `email` serves as the business
        email), a full structured `registered_address`, `company_type`, and
        `owners` - at least one associated person owning ≥25%, added via `POST
        /customers/{customer_id}/associated_persons`.
      required: [ legal_name, registration ]
      properties:
        legal_name: { type: string, description: "Registered legal name of the company." }
        trade_name:
          {
            type: string,
            description: "Trading / doing-business-as name, if different from the legal
              name."
          }
        registration: { $ref: "#/components/schemas/Registration" }
        incorporation_date:
          {
            type: string,
            format: date,
            description: "Date the company was incorporated (ISO 8601 date)."
          }
        legal_structure:
          {
            type: string,
            description: "Legal form of the entity (e.g. Ltda, LLC, S.A.). Accepted and
              stored on the customer record, and it is NOT requested in
              `requirements.missing_fields`: no automated assessment reads it
              today, so it is offered for completeness rather than asked for.
              For Brazil the field an onboarding actually reads is
              `company_type`."
          }
        company_type:
          {
            type: string,
            description: "Company classification. For Brazil the onboarding values are `MEI`
              (microempreendedor individual) and `PJ` (pessoa jurídica).
              Required for Brazilian onboarding; optional at creation."
          }
        industry: { type: string, description: "Primary industry or line of business." }
        website: { type: string, format: uri, description: "Company website URL." }
        phone:
          allOf:
            - $ref: "#/components/schemas/Phone"
          description: >-
            Structured contact phone for the company (dialing code + number).
            Required for Brazilian onboarding.
        registered_address: { $ref: "#/components/schemas/Address" }
        source_of_funds:
          {
            type: string,
            description: "Declared source of the funds the business will move. Reported in
              `requirements.missing_fields` until supplied."
          }
        expected_monthly_volume_usd:
          {
            type: number,
            minimum: 0,
            description: "Expected monthly transaction movement in USD. This is the amount
              the economic-capacity assessment MEASURES; what it measures
              against is the declared capacity (`monthly_income_usd` for an
              individual, `annual_revenue_usd` for a business). Supplying this
              alone yields no assessment. Reported in
              `requirements.missing_fields` until supplied."
          }
        annual_revenue_usd:
          {
            type: number,
            minimum: 0,
            description: "Declared annual revenue in USD (amounts are USD-denominated). This
              is the CAPACITY the expected monthly volume is measured against
              (the assessment reads it as `annual_revenue_usd / 12`): without it
              the economic-capacity assessment has nothing to compare and
              returns no verdict. `0` is a valid answer and is not treated as
              absent. Reported in `requirements.missing_fields` until supplied."
          }
        is_regulated_activity:
          {
            type: boolean,
            description: "Whether the business performs a regulated activity. A declared
              value (including `false`) counts as answered; absent is reported
              in `requirements.missing_fields`. When `true`, supply `regulator`
              and `regulated_activity_details`."
          }
        regulator:
          {
            type: string,
            description: "Name of the supervising regulator when the activity is regulated."
          }
        regulated_activity_details:
          {
            type: string,
            description: "Detail of the regulated activity (licence, scope) when regulated."
          }
        nature_of_business:
          {
            type: string,
            description: "Description of what the business does / the services it provides."
          }
        aml_program:
          {
            type: string,
            description: "Description of the business's compliance / AML program. Accepted
              and stored on the customer record, and it is NOT requested in
              `requirements.missing_fields`: no automated assessment reads it
              today, so it is offered for completeness rather than asked for. "
          }
        operating_jurisdictions:
          {
            type: array,
            items: { type: string, pattern: "^[A-Z]{2}$" },
            description: "Countries the business operates in (ISO 3166-1 alpha-2 codes).
              Accepted and stored on the customer record, and it is NOT
              requested in `requirements.missing_fields`: no automated
              assessment reads it today, so it is offered for completeness
              rather than asked for. Sanctions screening does not currently read
              this list."
          }
        account_purpose:
          {
            type: string,
            description: "Intended use of the account / purpose of the relationship."
          }
    CustomerVerification:
      type: object
      description: The customer's verification posture - the mode applied, the overall
        status, and per-check results.
      properties:
        mode:
          type: string
          enum: [ full, reliance ]
          description: |
            How this customer is verified:
            - `full` - OZAV performs the verification checks directly.
            - `reliance` - you attest, under a reliance arrangement, that verification
              was performed on your side; available only to organizations holding the
              `kyc_reliance` entitlement (contractual attestation). Reliance mode does
              not auto-approve a customer in the public API response; the customer's
              `status` reflects the current onboarding state.

            Designated-list screening is always performed by OZAV regardless of mode.

            **When `mode` is absent.** This field is optional, and its absence is meaningful:
            OZAV cannot state a verification posture for this record. Treat it as
            "posture unknown". It is NOT equivalent to `full`, and must never be read as one.

            Two different internal situations produce it, and the API does not distinguish them:
            either no verification posture was ever recorded (typical for records created outside
            the API v2 onboarding path, which carry no canonical verification block), or a posture
            exists internally but is not expressible in this API version. Both mean the same thing
            to you: do not treat this record as verified on the strength of this object.

            **Precedence against `status`.** `status` is required and is always present. When
            `mode` is absent, `status` describes the internal onboarding state ONLY and is not a
            verification attestation - a record can read `status: approved` with no `mode`, and
            that combination must not be used as evidence that verification was performed.

            **`requirements.missing_fields` is a lower bound here.** It is still emitted and still
            useful, but when the canonical block is missing OZAV cannot resolve the record's
            jurisdiction, so jurisdiction-specific requirements are not listed. Satisfying every
            field it names may still leave the record incomplete.

            The record is still returned in full, and other records are unaffected: one
            incomplete record never fails a call.
        status: { $ref: "#/components/schemas/CustomerStatus" }
        checks:
          type: object
          description: Per-capability check results, keyed by `CapabilityKind`. Empty
            until checks have run.
          additionalProperties: { $ref: "#/components/schemas/CheckResult" }
    CustomerDecision:
      type: object
      description: |
        OZAV's compliance decision on this customer, as it stands - the answer to
        "was it approved, and if not, why, and whose move is it".

        `status` is finer than the customer's `status`: a customer reads `pending`
        while OZAV is reviewing AND while OZAV is waiting on documents from you,
        and only `decision.status` tells those apart (`pending_review` vs
        `information_requested`). Read `requirements.owner` for whose move it is.

        `reason_code` is a closed vocabulary (below) and `reason` is its English
        label. Free-text analyst notes never travel: a rejection recorded before
        this vocabulary existed reads `policy`.

        Present on `GET /customers/{customer_id}`, on every record of
        `GET /customers`, and in the `customer.updated` webhook body - which is
        emitted on approval, on rejection, and when information is requested.
      required: [ status ]
      properties:
        status:
          type: string
          enum: [ pending_review, information_requested, approved, rejected ]
          description: |
            - `pending_review` - OZAV is reviewing; nothing is owed by you right now.
            - `information_requested` - OZAV asked for documents or corrections;
              `requirements.documents_due` lists them with the deadline.
            - `approved` / `rejected` - final.
        reason_code: { $ref: "#/components/schemas/DecisionReasonCode" }
        reason:
          type: string
          description: English label of `reason_code`. Present only with `reason_code`.
        decided_at:
          type: string
          format: date-time
          description: When the current decision was recorded. Absent while
            `pending_review`.
    CustomerSla:
      type: object
      description: |
        OZAV's response-time commitment on this customer, as a clock: whose move
        it is, until when, and whether the deadline has passed.

        - OZAV decides within **48 business hours** (Mon-Fri, Brazilian national
          holidays excluded) once the record is complete - profile fields filled
          and the required documents accepted. While that clock runs `owner` is
          `ozav`.
        - When OZAV asks you for a document or a correction the clock is yours:
          **30 days** for a document, **15 days** for a correction (`owner`
          `integrator`; `due_at` mirrors `requirements.documents_due[].due_at`).
        - `owner` is `none` when the decision is final, when no onboarding case is
          open, or while OZAV is waiting on a third party (that wait counts
          against no one).

        Present on `GET /customers/{customer_id}`, on every record of
        `GET /customers`, and in the `customer.updated` webhook body.
      required: [ owner, due_at, breached ]
      properties:
        owner:
          type: string
          enum: [ ozav, integrator, none ]
          description: Whose clock is running.
        due_at:
          type: [ string, "null" ]
          format: date-time
          description: When the running clock expires. `null` when `owner` is `none`.
        breached:
          type: boolean
          description: "`true` when `due_at` is in the past and the move is still owed.
            Always `false` when `owner` is `none`."
    DecisionReasonCode:
      type: string
      description: |
        Why a customer was rejected, or why information was requested. Closed
        vocabulary; each code has a fixed English label in `decision.reason`.

        Rejection codes: `sanctions_match`, `pep_unmitigated`, `identity_failed`,
        `document_invalid`, `capacity_unproven`, `inconsistent_data`,
        `restricted_jurisdiction`, `no_response`, `fraud_suspected`, `policy`.

        Request codes (also on `requirements.documents_due[].reason_code` and on
        the `requirement.due` webhook `reason`): `document_missing`,
        `document_invalid`, `data_inconsistent`, `capacity_unproven`,
        `identity_session_needed`, `half_of_limit_reached`.
      enum:
        [
          sanctions_match,
          pep_unmitigated,
          identity_failed,
          document_invalid,
          capacity_unproven,
          inconsistent_data,
          restricted_jurisdiction,
          no_response,
          fraud_suspected,
          policy,
          document_missing,
          data_inconsistent,
          identity_session_needed,
          half_of_limit_reached
        ]
    DocumentDue:
      type: object
      description: A document OZAV's compliance asked for on this customer, with the
        deadline.
      required: [ key, label, status ]
      properties:
        key:
          type: string
          description: The requirement key (e.g. `proof_of_address`, `bank_statement`,
            `government_id_front`).
        doc_type:
          $ref: "#/components/schemas/CustomerDocumentType"
          description: The `doc_type` to send in `POST /customers/{customer_id}/documents`
            to satisfy this item - the same vocabulary as `still_required` on
            the document list. Absent when the requirement is not an API
            document.
        label: { type: string, description: Human label of the requirement. }
        status:
          type: string
          enum: [ due, submitted, accepted, rejected ]
          description: |
            `due` - owed by you, the clock runs. `submitted` - a file landed and stopped
            the clock; OZAV still has to accept it. `accepted` - done. `rejected` - the
            file was refused; a new `due` line follows with the reason.
        due_at:
          type: [ string, "null" ]
          format: date-time
          description: Deadline for `due` items; `null` when none applies.
        reason_code: { $ref: "#/components/schemas/DecisionReasonCode" }
    Requirements:
      type: object
      description: >-
        Outstanding items blocking the customer from advancing through
        verification / onboarding - the honest "here's what's still needed".
        Present on the create response, on `GET /customers/{customer_id}`, and
        on every record in `GET /customers`. The list case matters:
        `CustomerVerification.mode` points here as the signal to read when a
        verification posture is absent, and that guidance would be unbacked if
        the field were not guaranteed on the list.
      properties:
        owner:
          type: string
          enum: [ integrator, ozav, none ]
          description: |
            Whose move it is. `integrator` - OZAV is waiting on you: documents due,
            information requested, OR an approved customer with a non-empty `due`
            (see the next field). `ozav` - OZAV is reviewing; nothing to do.
            `none` - the decision is final AND nothing is owed: a rejection, or an
            approval whose `due` is empty.

            Read `documents_due`, `due` and `decision.status` together for what is
            actually owed. `missing_fields` alone does NOT flip this to
            `integrator`: `owners` is unreachable for a business (see below).

            An approved customer with a non-empty `due` returns `integrator`, not
            `none` - that combination used to read as "decision final, nothing to
            do" while the account was in fact blocked. A REJECTED customer keeps
            `none` even with a non-empty `due`: the list survives the verdict, and
            no field reverses a rejection.
        documents_due:
          type: array
          description: |
            What OZAV's compliance asked for on this customer - document, status and
            deadline - and why (`reason_code`). It shrinks as files land (`submitted`)
            and are accepted. The `requirement.due` webhook announces a new ask; this
            field is the pull side of the same fact.
          items: { $ref: "#/components/schemas/DocumentDue" }
        due:
          type: array
          description: >-
            What the customer still owes before the ACCOUNT can operate - which
            is a different question from whether the verification passed. Empty
            is the normal state; a non-empty `due` on an `approved` customer
            means the verification is done and the account is still blocked, and
            `owner` reads `integrator`.

            The set below is closed and comes from the check that guards
            withdrawals. The strings are human-readable Portuguese today, not
            machine tokens, so match them exactly rather than parsing them:
            `nome do titular` (holder name), `CPF valido` (a valid Brazilian
            individual tax id), `CNPJ valido` (a valid Brazilian company
            registration), `documento` (a tax id of any kind, for a
            non-Brazilian holder), `endereco (logradouro e cidade)` (street and
            city). A Brazilian company with a valid CNPJ is exempt from the
            address - the public registry already carries it.

            Capability kinds and document requests can also appear here from the
            onboarding path; `documents_due` is the structured view of those,
            with deadline and reason.

            To clear an entry, send the corresponding field with `PATCH
            /customers/{customer_id}`. The check re-runs on that call: when it
            passes, the account activates and this array comes back empty in the
            same response. It is never removed, only emptied - an absent `due`
            would be indistinguishable from one nobody computed.
          items: { type: string }
        due_codes:
          type: array
          description: >-
            The same thing as `due`, in stable machine tokens - added because
            `due` carries human-readable Portuguese and some integrators compare
            it by equality. `due` is unchanged and stays; read whichever fits
            your code.

            The vocabulary is deliberately the SAME as `missing_fields`:
            `legal_name`, `tax_id`, `registration`, `residential_address`,
            `registered_address`. Two names for the same thing inside one object
            would be worse than prose. The token depends on the customer type -
            for a business, a missing document is `registration` and a missing
            address is `registered_address`.

            Only entries that come from the account check get a code. A document
            request also lands in `due`, and its structured home is
            `documents_due` (with deadline and reason) - inventing a token for
            it here would promise vocabulary nothing emits. So `due_codes` can
            be SHORTER than `due`; it is not positional.
          items: { type: string }
        missing_fields:
          type: array
          description: >-
            Identity/onboarding fields still required for this customer,
            computed from its own data and JURISDICTION-AWARE from the tax-id /
            registration country. Each token is the PATH OF THE REQUEST FIELD
            you must supply, minus the `individual.` / `business.` prefix - so
            `date_of_birth` means `individual.date_of_birth`, and
            `residential_address` means `individual.residential_address`. Match
            them exactly; the set below is closed and generated from the code
            that emits it.

            For a Brazilian individual: `mother_name`, `date_of_birth`, `phone`,
            `email`, `residential_address` (a full structured address), and
            `tax_id`. For a Brazilian business: `phone`, `email`,
            `registered_address`, `company_type`, and `owners` (at least one
            associated person owning ≥25%). Outside Brazil a base set applies
            (`legal_name`, `tax_id` / `registration`, `email`, and the address
            of the matching block).

            Independently of jurisdiction, the risk profile is also reported
            here until answered: `source_of_funds` and
            `expected_monthly_volume_usd` for both customer types, plus
            `is_regulated_activity` and `expected_daily_volume_usd` for a
            business. A declared `false` counts as answered and is NOT reported.

            The economic-capacity DECLARATION is reported alongside the expected
            volume, and the pair is what makes the assessment possible:
            `monthly_income_usd` for an individual and `annual_revenue_usd` for
            a business. The expected volume is the amount being measured; the
            income or revenue is what it is measured AGAINST. Supplying only the
            volume produces no verdict. `0` is a valid answer for either and is
            NOT reported as still owed.

            An individual also owes `occupation`, `nationality`, `is_pep` and
            `account_purpose`. `is_pep` is a politically-exposed-person
            self-declaration: an explicit `false` is an answer, an absent field
            is an unanswered question.

            `regulator` and `regulated_activity_details` are CONDITIONAL: they
            are reported only while `is_regulated_activity` is `true`. A `false`
            answer does not ask for them, and an ABSENT `is_regulated_activity`
            asks for that question itself rather than for its follow-up.

            `expected_daily_volume_usd` is a separate declaration, not the
            monthly figure divided by anything: account provisioning asks for
            both, and a derived number would be a value the customer never
            declared.

            A business also owes `nature_of_business`, `website`,
            `account_purpose` and `incorporation_date`. They stay OPTIONAL on
            `POST /customers` - the customer is created and completed over time
            - but account opening asks for them later, so they are reported here
            from the start rather than surfacing as a refusal once your own
            customer is already waiting.

            An empty array means nothing is outstanding for the tokens that can
            be cleared. ⚠️ `owners` is currently NOT one of them: it is reported
            for every business regardless of the associated persons on file,
            because neither read path supplies the qualifying-owner count. An
            empty array is therefore unreachable for a business, and gating your
            flow on it will block every business customer permanently. Read the
            tokens you care about instead of testing the array for emptiness.
          items:
            type: string
            enum:
              - mother_name
              - expected_daily_volume_usd
              - nature_of_business
              - website
              - account_purpose
              - incorporation_date
              - date_of_birth
              - phone
              - email
              - residential_address
              - registered_address
              - tax_id
              - registration
              - legal_name
              - company_type
              - owners
              - representative
              - source_of_funds
              - expected_monthly_volume_usd
              - is_regulated_activity
              - monthly_income_usd
              - annual_revenue_usd
              - occupation
              - nationality
              - is_pep
              - regulator
              - regulated_activity_details
    CustomerCreateRequest:
      type: object
      required: [ type, email ]
      description: |
        `email` is required. An `individual` customer additionally requires `individual.tax_id`;
        a `business` customer requires `business.registration` (enforced server-side).
      properties:
        type:
          type: string
          description: |
            Whether the customer is a natural person or a company:
            - `individual` - a person; supply the `individual` block (including `individual.tax_id`).
            - `business` - a company; supply the `business` block (including `business.registration`).
          enum: [ individual, business ]
        external_ref:
          {
            type: string,
            description: "Your own identifier for this customer, echoed back on the
              resource. Optional; need not be unique."
          }
        email:
          {
            type: string,
            format: email,
            description: "Contact email for the customer. Required."
          }
        phone:
          {
            type: string,
            description: "Contact phone number in E.164 form (e.g. +5511999998888).
              Optional."
          }
        individual: { $ref: "#/components/schemas/CustomerIndividual" }
        business: { $ref: "#/components/schemas/CustomerBusiness" }
        verification_mode:
          type: string
          description: |
            The verification posture for THIS customer (see `CustomerVerification.mode`).
            Optional: omitted, the organization's posture applies - `reliance` when
            your organization holds the `kyc_reliance` product (the default for API
            integrators), otherwise `full`.
            - `reliance` - you verified the person and attest it; the end-customer does
              nothing. OZAV still screens against designated lists and assesses
              economic capacity, and approves automatically once the profile is
              complete, the partner document is `received` and screening is clear.
              OZAV terms (`terms_session_id`) are NOT required. Requires the
              `kyc_reliance` entitlement (`403 ENTITLEMENT_REQUIRED` otherwise).
            - `full` - OZAV verifies the end-customer in a hosted session
              (`POST /verifications`: document, liveness, face match).
          enum: [ full, reliance ]
        monitoring:
          type: boolean
          default: false
          description: Enable ongoing designated-list monitoring for this customer.
        terms_session_id:
          type: string
          description: |
            The id (`ts_…`) of an ACCEPTED terms-of-service session. NOT required
            for a `reliance` customer (the default posture for API integrators):
            the contract with the end-customer is yours. For `full` customers, mint
            it with `POST /customers/terms-sessions`, present `terms_url` to your
            end user, record the acceptance with `POST /customers/terms-sessions/accept`,
            then pass the same id here. Consumed at provisioning for personas that
            settle or hold funds: provisioning a settlement customer fails closed
            with `422 TERMS_ACCEPTANCE_REQUIRED` when there is no accepted session,
            and the customer stays unprovisioned (`status: pending`). Not required
            for compliance-only (verification-only) personas, which are never
            provisioned at a settlement layer.

            SANDBOX: a test key never resolves this field - the attestation is
            simulated and any value (or none) is accepted. The gate is real only on
            `live`. Do not read a sandbox `201` as proof that your terms flow works.
        self_custody: { $ref: "#/components/schemas/CustomerSelfCustody" }
        custody_model:
          type: string
          enum: [ managed, self_custody ]
          description: |
            Which custody to provision for THIS customer, when your organization
            has both custody products enabled.

            Omit it and the default applies: `self_custody` when your
            organization has it, otherwise `managed`. That default is why this
            field exists - before it, holding both products removed the choice
            instead of offering it, and every customer came out `self_custody`
            (no deposit address, and no custody balance for an off-ramp to
            debit).

            Asking for a model your organization has not contracted returns
            `403 ENTITLEMENT_REQUIRED` naming the missing product - never a
            silent fallback to the other model. When it resolves to
            `self_custody`, `self_custody.address` is required.
        metadata: { $ref: "#/components/schemas/Metadata" }
    CustomerSelfCustody:
      type: object
      required: [ chain, address ]
      description: |
        Bring-your-own-wallet block for self-custody provisioning. Supply the
        end-user-controlled wallet address on the given chain; OZAV records it and
        never opens a managed wallet for it (the end-user holds the key). Consumed
        only when your organization holds the `self_custody` product; when the
        provisioning plan resolves to self-custody and this block is missing, the
        call fails closed with `422 SELF_CUSTODY_ADDRESS_REQUIRED` and the customer
        stays unprovisioned. The resulting wallet reports
        `custody_model: self_custody`.

        **How the plan resolves, and what to do when you did not ask for
        self-custody.** The create request does not choose a custody model. The
        model comes from the products your organization holds, and **self-custody
        outranks managed custody when both are enabled** - so an organization with
        both gets self-custody on every customer, and a request without this block
        is refused even though it never asked for self-custody.

        If you meant managed custody, that refusal is not something you can fix in
        the request body: ask OZAV to disable `self_custody` for your organization.
        The `422` message says this too, so the response is self-explanatory.

        ⚠️ **This block accepts more chains than `POST /wallets` does.** The two
        chain lists are deliberately different: the enum here is the wider one,
        and `POST /wallets` accepts a narrower set. Registering a second address
        later on one of the chains only this block accepts is refused. If a chain
        matters to you and it is not in the `POST /wallets` enum, ask before you
        design around it.
      properties:
        chain:
          type: string
          enum:
            [
              ethereum,
              base,
              optimism,
              polygon,
              avalanche_c_chain,
              worldchain,
              unichain,
              linea,
              sonic,
              sei,
              celo,
              zksync_era,
              solana,
              tron,
              stellar
            ]
          description: |
            The chain the end-user-controlled address lives on. Public API v2 accepts
            the active EVM networks listed in `Network`, plus Solana, Tron, and
            Stellar. The address must be valid on the chosen chain; OZAV records it
            as-is and never derives or holds a key for it.
        address:
          type: string
          minLength: 1
          description: The end-user-controlled wallet address on `chain`.
    CustomerUpdateRequest:
      type: object
      description: Mutable customer attributes. Only the fields you include are
        changed; omitted fields are left untouched.
      properties:
        email: { type: string, format: email, description: "Replacement contact email." }
        phone: { type: string, description: "Replacement contact phone number (E.164)." }
        individual: { $ref: "#/components/schemas/CustomerIndividual" }
        business: { $ref: "#/components/schemas/CustomerBusiness" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    TermsSession:
      type: object
      description: A terms-of-service session. Customer provisioning at a settlement
        partner is fail-closed on one of these being accepted.
      required: [ object, status, terms_url, terms_version, expires_at ]
      properties:
        id:
          {
            type: string,
            description: "The session token, prefixed `ts_`. Returned ONLY on creation -
              OZAV stores just its hash. Persist it; it is never returned again,
              not even on an idempotent replay."
          }
        object: { type: string, enum: [ terms_session ] }
        status:
          {
            type: string,
            enum: [ pending, accepted ],
            description: "`pending` until you record the acceptance; only `accepted`
              satisfies `POST /customers`. There is no hosted page in between,
              so there is no intermediate state."
          }
        terms_url:
          {
            type: string,
            format: uri,
            description: "The terms to present to your end user."
          }
        terms_version:
          {
            type: string,
            description: "The version in force when the session was created. Acceptance is
              recorded against THIS version, not the current one."
          }
        expires_at: { type: string, format: date-time }
        accepted_at:
          {
            type: string,
            format: date-time,
            description: "Present once the acceptance has been recorded."
          }
    TermsSessionAcceptRequest:
      type: object
      description: Your attestation that the end user accepted the terms, with the
        evidence you collected.
      required: [ terms_session_id, accepted_by ]
      properties:
        terms_session_id:
          {
            type: string,
            description: "The `id` returned by `POST /customers/terms-sessions`."
          }
        accepted_by:
          type: object
          description: Who accepted.
          required: [ legal_name ]
          properties:
            legal_name: { type: string }
            email: { type: string, format: email }
        accepted_at:
          {
            type: string,
            format: date-time,
            description: "When they accepted. ISO-8601 with an explicit UTC offset, not in
              the future. Defaults to the time of this call."
          }
        ip_address: { type: string }
        user_agent: { type: string }
    Customer:
      type: object
      description: An end-user you serve - an individual or a business. The anchor
        resource that every other object references by its `customer_id`.
      required: [ id, object, type, status ]
      properties:
        id:
          {
            type: string,
            description: "Opaque customer identifier, prefixed `ozav_cus_`."
          }
        object:
          {
            type: string,
            const: customer,
            description: "Resource type discriminator; always the literal `customer`."
          }
        type:
          type: string
          description: Whether the customer is a natural person (`individual`) or a
            company (`business`).
          enum: [ individual, business ]
        status: { $ref: "#/components/schemas/CustomerStatus" }
        account_status:
          type: string
          enum: [ active, pending ]
          description: >-
            Whether the ACCOUNT can operate - a different question from whether
            the VERIFICATION passed, which is `status`. `active` means named
            accounts, cards and payouts are available. `pending` means they are
            not, and the calls that need them refuse with `422
            ACCOUNT_NOT_ACTIVATED`.

            An approved customer can sit at `pending` for two different reasons.
            When data is missing, `requirements.due` names it and
            `requirements.owner` reads `integrator` - send the field with `PATCH
            /customers/{customer_id}` and the account activates in the same
            response. When `due` is empty, the account is simply not provisioned
            yet and nothing is owed by you.

            Read this field instead of inferring operability from `status`: an
            approved customer is not necessarily one that can move money.
        decision: { $ref: "#/components/schemas/CustomerDecision" }
        sla: { $ref: "#/components/schemas/CustomerSla" }
        external_ref:
          {
            type: string,
            description: "Your own identifier for this customer, as supplied at creation."
          }
        email:
          {
            type: string,
            format: email,
            description: "Contact email for the customer."
          }
        phone: { type: string, description: "Contact phone number (E.164)." }
        individual: { $ref: "#/components/schemas/CustomerIndividual" }
        business: { $ref: "#/components/schemas/CustomerBusiness" }
        verification: { $ref: "#/components/schemas/CustomerVerification" }
        requirements: { $ref: "#/components/schemas/Requirements" }
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the customer was created (RFC 3339 / ISO 8601, UTC)."
          }
        updated_at:
          {
            type: string,
            format: date-time,
            description: "When the customer was last modified (RFC 3339 / ISO 8601, UTC)."
          }
        metadata: { $ref: "#/components/schemas/Metadata" }
    CustomerDocumentType:
      type: string
      description: |
        The kind of KYC/KYB document. Jurisdiction-agnostic by design - generic
        categories, never provider fields or country-specific artifact names -
        because this API onboards your customers worldwide.

        **What this list is, and what it is not.** It is the vocabulary for
        DECLARING what you are uploading. It is not a per-country list of which
        artifact satisfies which category: whether a given national ID, driving
        licence or utility bill is accepted for a specific customer is decided
        in review, not by this enum, and OZAV does not publish a per-jurisdiction
        acceptance table. Declare the category that best describes the artifact
        and upload it; if something is missing or unusable you get a named
        outcome on the verification, not a silent rejection.

        The FIELDS a customer must supply are a separate question, answered by
        `requirements.missing_fields` on the customer itself. That set is driven
        by the country on `tax_id` / `registration`, and it does not vary by the
        currency of any account you later open.

        **Required versus optional, and why nothing here blocks.** For an
        international account the receiving institution asks for a defined set of
        company information. Some categories below satisfy part of that set and
        some do not apply to every company - a Brazilian company, for example, has
        no separate memorandum of association, because its Contrato Social is both
        the constitutive act and the articles.

        So this API accepts every category at any time and refuses none of them for
        being absent. Upload what the company has, under the category that
        describes it.

        **What this API does not do yet, said plainly.** `requirements` on
        `GET /customers/{id}` reports missing onboarding *fields*, not missing
        documents - it will not tell you which of the categories above are still
        outstanding. Document review happens at the receiving institution, and
        today its outcome reaches you through your account contact, not through
        this API. Publishing the list here without saying that would suggest a
        completeness signal that does not exist.

        A company that cannot produce one of these is not blocked from being
        created. It is blocked, later and visibly, from having an account opened -
        and the difference matters: the first is a wall at the door, the second is
        a list you can work through.
      enum:
        - identity_document
        - selfie
        - liveness
        - proof_of_address
        - company_registration
        - ownership_structure
        - financial_statement
        - source_of_funds
        - bank_statement
        - authorized_signature_form
        - banking_reference
        - corporate_resolution
        - good_standing_certificate
        - source_of_funds_certification
        - other
    CustomerDocumentUploadRequest:
      type: object
      additionalProperties: false
      required: [ doc_type ]
      properties:
        subject_type:
          type: string
          enum: [ customer, associated_person ]
          default: customer
          description: "Who this document belongs to. Omit for customer-scoped documents."
        associated_person_id:
          type: string
          description: "Required when subject_type is associated_person. Opaque
            `ozav_ap_...` id."
        doc_type: { $ref: "#/components/schemas/CustomerDocumentType" }
        side:
          type: string
          enum: [ front, back, single ]
          description: "Document side when applicable."
        issuing_country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: "ISO 3166-1 alpha-2 issuing country when applicable."
        mime:
          type: string
          description: "MIME type of the file to upload (e.g. application/pdf, image/png).
            Optional integrity hint."
          example: application/pdf
        size_bytes:
          type: integer
          minimum: 1
          description: "Declared file size in bytes. Optional integrity hint."
        sha256:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: "Lowercase hex SHA-256 of the file. Optional integrity hint;
            rejected if malformed."
    CustomerDocument:
      type: object
      description: A customer's uploaded supporting document. Never exposes the raw
        storage location.
      properties:
        id:
          {
            type: string,
            description: "Opaque document id.",
            example: ozav_doc_01J8Z9K2M3N4P5Q6R7S8T9U0V1
          }
        object:
          type: string
          enum: [ document ]
          description: Resource type discriminator; always the literal `document`.
        customer_id: { type: string, description: "The customer this document belongs to." }
        subject_type:
          type: string
          enum: [ customer, associated_person ]
          description: Whose document this is. `customer` - the customer itself.
            `associated_person` - one of a business customer's associated
            persons, named in `associated_person_id`.
        associated_person_id:
          type: string
          description: "Present when subject_type is associated_person."
        doc_type: { $ref: "#/components/schemas/CustomerDocumentType" }
        side:
          type: [ string, "null" ]
          enum: [ front, back, single, null ]
          description: Which face of a two-sided document these bytes are. `single` for a
            document captured in one image. `null` where the document type has
            no sides (a PDF statement).
        issuing_country:
          type: [ string, "null" ]
          description: ISO 3166-1 alpha-2 country that issued the document, when known.
            `null` when the document type has no issuer (a proof of address) or
            it was not supplied.
        status:
          type: string
          enum: [ pending_upload, received, rejected ]
          description: "`pending_upload` = grant issued, bytes not yet confirmed;
            `received` = bytes present; `rejected` = failed review."
        mime:
          type: [ string, "null" ]
          description: Content type of the stored bytes, as observed on upload. `null`
            while `status` is `pending_upload`.
        size_bytes:
          type: [ integer, "null" ]
          description: Size of the stored bytes. `null` while `status` is `pending_upload`.
        sha256:
          type: [ string, "null" ]
          description: SHA-256 of the stored bytes, computed by OZAV on receipt - not a
            value you send. Compare it against your own digest to prove the
            upload was not truncated. `null` while `status` is `pending_upload`.
        review: { $ref: "#/components/schemas/DocumentReview" }
        created_at:
          type: string
          format: date-time
          description: When the upload grant was created (RFC 3339, UTC), not when bytes
            arrived.
        updated_at:
          type: string
          format: date-time
          description: When the record last changed (RFC 3339, UTC) - the receipt of bytes
            and a review outcome both move it.
    DocumentReview:
      type: object
      description: |
        OZAV's own review of a supporting document (proof of address, bank
        statement, proof of income, company document). Identity documents and
        selfies are verified in the hosted verification session instead and
        read `not_reviewed` here.

        `rejected` comes with a closed `reason_code` and its English label; a
        fresh `requirements.documents_due` line follows with a new deadline. The
        `document.reviewed` webhook carries the same outcome. Omitted (not
        `not_reviewed`) when the review could not be read at response time.
      required: [ status ]
      properties:
        status:
          type: string
          enum: [ accepted, rejected, in_review, not_reviewed ]
          description: |
            - `not_reviewed` - no review yet (or not a document OZAV reviews).
            - `in_review` - waiting on a compliance officer.
            - `accepted` - the document satisfies the requirement.
            - `rejected` - refused; see `reason_code`. Send a new file.
        reason_code: { $ref: "#/components/schemas/DocumentReviewReasonCode" }
        reason:
          type: string
          description: English label of `reason_code`. Present only with `reason_code`.
        reviewed_at:
          type: string
          format: date-time
          description: When the current review was recorded. Absent while `not_reviewed`.
    DocumentReviewReasonCode:
      type: string
      description: Why a document was rejected. Closed vocabulary; each code has a
        fixed English label in `review.reason`.
      enum:
        [
          unreadable,
          not_the_requested_document,
          holder_mismatch,
          holder_is_not_the_company,
          expired,
          address_mismatch,
          low_confidence,
          tampering_signals,
          missing_period,
          officer_decision
        ]
    CustomerCredential:
      type: object
      description: A portable verification credential (IETF SD-JWT, `vc+sd-jwt`)
        signed by OZAV. Give `credential` to the end user; keep `id` to look the
        credential up at `status_url`.
      required:
        [
          id,
          object,
          customer_id,
          format,
          credential,
          kyc_level,
          method,
          issued_at,
          expires_at,
          jwks_url,
          status_url
        ]
      properties:
        id:
          type: string
          description: "Opaque credential id (the JWT `jti`)."
          example: ozav_vc_Q2xhdWRlRmFibGU1MQ
        object:
          type: string
          enum: [ credential ]
          description: Resource type discriminator; always the literal `credential`.
        customer_id: { type: string, description: "The customer this credential attests." }
        format:
          type: string
          enum: [ vc+sd-jwt ]
          description: Always `vc+sd-jwt` - an IETF SD-JWT with selectively disclosable
            claims.
        credential:
          type: string
          description: The compact SD-JWT, `<jwt>~<disclosure>~...~`. Hand it to the end
            user; do not store it as personal data - the disclosures carry the
            CPF, name and birth date in clear.
        kyc_level:
          type: integer
          enum: [ 2, 3 ]
          description: "`3` = hosted session (document, liveness, face match); `2` =
            document-based identification (reliance or manual review)."
        method:
          type: string
          enum: [ hosted_session, reliance, manual_review ]
          description: How OZAV verified the customer.
        issued_at:
          type: string
          format: date-time
          description: When this credential was issued (RFC 3339, UTC).
        expires_at:
          type: string
          format: date-time
          description: When this credential stops being valid regardless of the customer's
            status.
        jwks_url:
          type: string
          format: uri
          description: Where OZAV publishes the signing keys (JWKS, `kid`-addressed).
            Public; no API key.
        status_url:
          type: string
          format: uri
          description: Public status of this credential, `valid` | `revoked` | `expired` |
            `unknown`. No API key; no personal data.
    CustomerDocumentUpload:
      description: A newly created document plus the short-lived signed URL to PUT the
        file bytes to.
      allOf:
        - $ref: "#/components/schemas/CustomerDocument"
        - type: object
          properties:
            upload_url:
              type: string
              description: |
                Short-lived upload URL - `PUT` the raw file bytes here. Not returned by the list
                endpoint.

                **Send your API key on this PUT as well.** The embedded `upload_token` scopes the
                grant to this one customer/document pair and expires on its own, but it does NOT
                replace authentication - a `PUT` without `X-API-Key` (or `Authorization: Bearer`)
                is rejected with `MISSING_API_KEY`.

                Because the key is required, **this URL is for your server, not your browser**: do
                not hand it to an end user's device, which would mean shipping your secret key with
                it. Upload the bytes from your backend.
            upload_expires_at:
              type: string
              format: date-time
              description: "When `upload_url` expires."
    AssociatedPersonCreateRequest:
      type: object
      description: A person associated with a business customer (UBO, director,
        officer, or signer), added for KYB.
      required: [ roles, individual ]
      properties:
        roles:
          type: array
          minItems: 1
          description: |
            One or more roles this person holds in the business:
            - `ubo` - ultimate beneficial owner (an owner above the ownership threshold).
            - `director` - a member of the board or a statutory director.
            - `officer` - an executive officer (e.g. CEO, CFO).
            - `authorized_signer` - authorized to act or sign on the account.
          items: { type: string, enum: [ ubo, director, officer, authorized_signer ] }
        ownership_percent:
          {
            type: number,
            minimum: 0,
            maximum: 100,
            description: "Percentage of the business this person owns, 0-100. Relevant for
              `ubo` roles."
          }
        individual: { $ref: "#/components/schemas/CustomerIndividual" }
        email:
          {
            type: string,
            format: email,
            description: "Contact email for this person. Optional."
          }
    AssociatedPersonUpdateRequest:
      type: object
      description: Mutable attributes of an associated person. Only the fields you
        include are changed.
      properties:
        roles:
          type: array
          description: Replacement set of roles - one or more of `ubo`, `director`,
            `officer`, `authorized_signer`.
          items: { type: string, enum: [ ubo, director, officer, authorized_signer ] }
        ownership_percent:
          {
            type: number,
            minimum: 0,
            maximum: 100,
            description: "Revised ownership percentage, 0-100."
          }
        individual: { $ref: "#/components/schemas/CustomerIndividual" }
    AssociatedPerson:
      type: object
      description: |
        A person linked to a business customer for KYB purposes.

        `verification.mode` is **inherited from the customer**, never set per
        person: an associated person has no verification posture of its own, so
        a customer onboarded under `reliance` reports `reliance` here too.

        When the customer has no recorded posture, `mode` is **omitted**. Absent
        is not `full`: it means OZAV has no posture on record for that customer,
        and `status` must not be read as a verification attestation while `mode`
        is absent.
      required: [ id, object, roles ]
      properties:
        id:
          {
            type: string,
            description: "Opaque associated-person identifier, prefixed `ozav_ap_`."
          }
        object:
          {
            type: string,
            const: associated_person,
            description: "Resource type discriminator; always the literal
              `associated_person`."
          }
        customer_id:
          {
            type: string,
            description: "The `ozav_cus_` id of the business customer this person belongs
              to."
          }
        roles:
          type: array
          description: |
            Roles this person holds - one or more of `ubo`, `director`, `officer`,
            `authorized_signer` (see `AssociatedPersonCreateRequest.roles`).
          items: { type: string, enum: [ ubo, director, officer, authorized_signer ] }
        ownership_percent:
          {
            type: number,
            description: "Percentage of the business this person owns, 0-100."
          }
        individual: { $ref: "#/components/schemas/CustomerIndividual" }
        email: { type: string, format: email, description: "Contact email for this person." }
        verification: { $ref: "#/components/schemas/CustomerVerification" }
        requirements:
          allOf:
            - $ref: "#/components/schemas/Requirements"
          description: |
            What is still missing **on this person** before an account can be opened
            for the customer they belong to.

            A business account is refused while a qualifying owner is incomplete, and
            that refusal names the person. This field is the same information delivered
            when you create the person, instead of when you request the account.

            The set depends on the customer's **jurisdiction**, never on a provider. A
            person attached to a Brazilian company owes the full KYB set:
            `individual.legal_name`, `individual.date_of_birth`, `individual.tax_id`,
            `email`, `individual.phone` and `individual.residential_address`, with the
            address complete: `line1`, `number`, `neighborhood`, `city`, `state` and
            `postal_code`. Outside Brazil only `individual.legal_name` and
            `individual.date_of_birth` are owed.
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the associated person was added (RFC 3339 / ISO 8601, UTC)."
          }
    ExchangeConditions:
      type: object
      description: |
        Public exchange conditions. `spread_bps` is the additional
        client-selected spread for the quote, in integer basis points. OZAV cost,
        floor, margin, split, and provider analytics are internal and are not
        serialized in public API responses.
      required: [ spread_bps ]
      properties:
        spread_bps:
          {
            type: integer,
            minimum: 0,
            description: "Additional client-selected spread, in integer basis points (100
              bps = 1%)."
          }
    Quote:
      type: object
      description: An FX quote - opaque, short-lived, and carrying the applied
        exchange conditions. Execute it by creating a transfer that references
        its `id`.
      required:
        [
          id,
          object,
          rate_type,
          rate,
          source,
          target,
          exchange_conditions,
          expires_at,
          created_at
        ]
      properties:
        id: { type: string, description: "Opaque quote identifier, prefixed `ozav_q_`." }
        object:
          {
            type: string,
            const: quote,
            description: "Resource type discriminator; always the literal `quote`."
          }
        customer_id:
          {
            type: string,
            description: "Opaque customer id (prefixed `ozav_cus_`) the quote was created
              for."
          }
        rate_type:
          type: string
          description: |
            How the `rate` behaves until execution:
            - `floating` - indicative; the rate re-derives at execution time.
            - `locked` - held fixed until `expires_at`.
          enum: [ floating, locked ]
        rate:
          {
            type: string,
            description: "The FX rate as a decimal string - target-currency units per one
              source unit."
          }
        source: { $ref: "#/components/schemas/MonetaryAmount" }
        target: { $ref: "#/components/schemas/MonetaryAmount" }
        exchange_conditions: { $ref: "#/components/schemas/ExchangeConditions" }
        expires_at:
          type: [ string, "null" ]
          format: date-time
          description: When the quote expires (RFC 3339 / ISO 8601, UTC). Null for
            floating quotes with no venue-side expiry.
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the quote was created (RFC 3339 / ISO 8601, UTC)."
          }
        facility: { $ref: "#/components/schemas/FacilityPreview" }
        facility_unavailable: { $ref: "#/components/schemas/FacilityUnavailable" }
    FacilityUnavailable:
      type: object
      description: >
        **Why the rate-lock preview is not available for this account.**


        Returned instead of `facility` when the request set `facility_preview:
        true` and the facility cannot be offered. It never fails the quote - the
        quote is the product - it only explains the absence.


        🔴 **Read this instead of guessing at a missing `facility` block.**
        Branch on `reason`, which is the stable slug and the same vocabulary as
        `details.reason` on `422 FACILITY_LOCK_UNAVAILABLE`, so one mapping
        serves the preview and the refusal.


        ⚠️ **This preview runs the offer gates only** - the ones that decide
        whether a lock can be SHOWN. It deliberately does not evaluate
        collateral or your contractual limits, because those are resolved at
        acceptance, inside the lock. So `collateral_insufficient` is **not** a
        value you will see here: it appears on `POST /transfers` instead. The
        table below is the complete set this endpoint can return.


        | `reason` | What it means | What to do | |---|---|---| |
        `client_not_exempt` | Your organization has not contracted the facility.
        | Talk to your account manager - no request will succeed until it is
        contracted. | | `no_client` | The key does not resolve to an
        organization the facility knows. | Contact support with the request id.
        | | `no_terms` / `terms_inactive` / `no_terms_version` / `invalid_terms`
        | The signed schedule (`Termo`) is missing, not active, or incomplete. |
        A contract step, not a parameter. Talk to your account manager. | |
        `past_client_cutoff` | Locking now is past today's cutoff. | Try earlier
        on the next business day. | | `client_window_exceeds_settlement` |
        Today's window does not fit settlement. | Ask for a shorter window, or
        try earlier. | | `unwind_not_ready` | The facility is not being offered
        right now. Not about your account. | Retry later; if it persists,
        contact support. | | `pool_config_missing` /
        `execution_margin_not_configured` / `settlement_mode_not_configured` | A
        platform setting the facility needs is missing. Not about your account.
        | Retry later; if it persists, contact support with the request id. | |
        `invalid_amounts` | The amounts on the quote cannot be used to price a
        lock. | Check `source.amount` and the target; if they look right,
        contact support. | | `previa_indisponivel` | The preview could not be
        computed. Not about your account, and not about your request. | The
        quote itself is valid and usable. If you need the preview, contact
        support with the `request_id` of this response. |
      readOnly: true
      required: [ reason, message ]
      properties:
        reason:
          type: string
          description: >-
            Stable, machine-readable slug. **Branch on this, never on
            `message`.**
        message:
          type: string
          description: >-
            Human-readable explanation, safe to show an operator. May change
            without notice, and is currently served in Portuguese on some paths.
    FacilityPreview:
      type: object
      description: >
        **The FX rate lock conditions (`trava de câmbio`), previewed alongside
        the quote.**


        Returned ONLY when the request set `facility_preview: true` AND your
        organization has the facility contracted and operable. Its
        `terms_version` is the value `POST /transfers` accepts as
        `facility_terms_version`, and this is the only place it comes from.


        ⚠️ **Absence is the answer, not an error.** A client without the
        facility gets a normal `201` quote with this block missing and no
        explanation - the preview never fails a quote, because the quote is the
        product. To learn WHY, attempt the transfer: it refuses loudly with `422
        FACILITY_LOCK_UNAVAILABLE` and a machine-readable `details.reason`.


        ⚠️ **These numbers are contractual, not settings.** The penalty, the
        minimum charge, the reserve percentage, the client window and the cutoff
        come from the schedule (`Termo`) your organization signed. They are
        shown here because an acceptance over conditions the payer never saw
        does not support a charge later.
      readOnly: true
      properties:
        terms_version:
          type: string
          description: >-
            The version of the signed schedule these conditions come from. Echo
            it back as `facility_terms_version` on `POST /transfers`. It must
            match byte for byte.
        locked_rate:
          {
            type: number,
            description: "The rate that would be locked, at this instant."
          }
        source_amount: { type: string, description: "How much is being locked, in the source unit." }
        source_asset: { type: string, description: "The source unit of `source_amount`." }
        expected_amount_minor:
          type: string
          description: >-
            What the customer receives at `locked_rate`, in the MINOR unit of
            `expected_amount_currency` (centavos for BRL). A string, because the
            value can exceed what a JSON number holds exactly.
        expected_amount_currency:
          type: string
          description: >-
            The currency of `expected_amount_minor`. Always `BRL` today; the
            field is present so the name never has to change when other payout
            currencies open.
        client_deadline_at:
          type: string
          format: date-time
          description: >-
            By when the customer must deliver (pay the Pix, or hand over the
            asset). Missing it is what triggers the penalty below.
        settlement_deadline_at:
          {
            type: string,
            format: date-time,
            description: "OZAV's own settlement deadline."
          }
        settlement_mode:
          {
            type: string,
            enum: [ T0, T1 ],
            description: "Settlement day for this lock."
          }
        cutoff_time_brt:
          type: string
          description: >-
            The daily cutoff, in BRT, from the schedule. ⚠️ **It and the client
            window cut each other short: the deadline is always whichever comes
            FIRST.** With a 120-minute window and a 17:00 cutoff, locking at
            16:00 gives 60 minutes, not 120, and locking at 16:30 gives 30. That
            is the schedule, not a defect - but it is the first thing that
            surprises an integrator, so branch on `client_deadline_at`, never on
            the window length.
        charge_rule:
          type: string
          description: >-
            How the charge is composed if the customer does not deliver in time.
            Cumulative, and the penalty is the large part.
        penalty_pct: { type: number, description: "Penalty, as a fraction. `0.05` is 5%." }
        minimum_charge_usd:
          type: number
          description: >-
            The minimum charge per unsettled operation, in USD. **Not basis
            points** - an absolute amount.
        reserve_pct:
          type: number
          description: >-
            The reserve (collateral) required, as a fraction of the OPEN
            POSITION - aggregate, including the new one, not per transaction.
            `0.02` is 2%. It must be constituted BEFORE acceptance and
            maintained while positions are open; if the ratio falls below the
            schedule's minimum, a top-up call is triggered.
        holiday_risk:
          type: boolean
          description: >-
            `true` when the deadline crosses a business-day boundary the
            calculation cannot resolve against a holiday calendar. Treat the
            deadline as approximate and confirm.
        capacity_guaranteed:
          type: boolean
          const: false
          description: >-
            **Always `false`.** This preview prices the lock; it does not
            reserve room under the shared ceiling, which is resolved at
            acceptance. Reading headroom here and deciding later is exactly the
            non-atomic pre-check the pool exists to prevent - two clients would
            both see "there is room" and both proceed.
    TransferStatus:
      type: string
      description: |
        Canonical transfer state machine. A successful FX transfer walks
        `awaiting_payment → payment_confirmed → converting → payout_initiated →
        completed`; the remaining states are terminal failures or attention states.

        | Value | Meaning |
        |---|---|
        | `awaiting_payment` | Transfer created; awaiting the customer's inbound payment. |
        | `payment_confirmed` | The inbound payment has been confirmed. |
        | `converting` | Funds are in flight and the FX conversion is underway (not yet confirmed). |
        | `payout_initiated` | The payout to the destination has been initiated. |
        | `completed` | Terminal success - funds delivered to the destination. |
        | `failed` | Terminal failure - no funds delivered. |
        | `cancelled` | Terminal - the transfer was cancelled. |
        | `expired` | Terminal - the transfer expired before completing. |
        | `needs_review` | Stuck and needs attention (e.g. underfunded, returned, in review); not terminal. |
        | `awaiting_approval` | A custody send held by YOUR approval quorum (`GET /wallets/{id}/policy` → `approvals`). Nothing has been signed; approver keys decide it at `POST /approvals/{id}/approve`. `approval_id` names the approval. Not terminal. |
      enum:
        [
          awaiting_payment,
          payment_confirmed,
          converting,
          payout_initiated,
          completed,
          failed,
          cancelled,
          expired,
          needs_review,
          awaiting_approval
        ]
    SettlementStatus:
      type: string
      description: |
        Canonical per-leg settlement status. A transfer carries an independent
        `source_leg` and `destination_leg`, each with one of these values.

        | Value | Meaning |
        |---|---|
        | `pending` | Accepted; awaiting the payer's funds. Non-terminal. |
        | `processing` | Funds in flight (received, or payout initiated). Non-terminal. |
        | `needs_review` | Stuck / needs attention (underfunded, in review, returned). Non-terminal. |
        | `completed` | Terminal success - the leg settled. |
        | `failed` | Terminal failure - the leg did not settle. |
        | `cancelled` | Terminal - the leg was cancelled. |
      enum: [ pending, processing, needs_review, completed, failed, cancelled ]
    SettlementLeg:
      type: object
      description: The settlement state of one side of a transfer (source or
        destination). Read-only.
      readOnly: true
      properties:
        status: { $ref: "#/components/schemas/SettlementStatus" }
        settled_at:
          {
            type: string,
            format: date-time,
            description: "When this leg reached terminal completion (RFC 3339 / ISO 8601,
              UTC). Present only once the leg is `completed`."
          }
    TransferSource:
      description: Where a transfer's funds come from. Discriminated by `type`; the
        value selects the flow together with the destination.
      oneOf:
        - $ref: "#/components/schemas/TransferSourceFiatPayment"
        - $ref: "#/components/schemas/TransferSourceWallet"
        - $ref: "#/components/schemas/TransferSourceCryptoPayment"
        - $ref: "#/components/schemas/TransferSourceAccount"
      discriminator:
        propertyName: type
        mapping:
          fiat_payment: "#/components/schemas/TransferSourceFiatPayment"
          wallet: "#/components/schemas/TransferSourceWallet"
          crypto_payment: "#/components/schemas/TransferSourceCryptoPayment"
          account: "#/components/schemas/TransferSourceAccount"
    TransferSourceAccount:
      type: object
      description: |
        The customer's OWN named fiat account (`ozav_acc_`) as the SOURCE of an on-ramp: the
        customer already deposited into that account, and the order is funded from its balance.
        No payment instructions are returned, because there is nothing left to pay.

        The account must be active and known to its institution, and the amount must fit the
        balance that is not already committed to the customer's other open orders. Choose
        `fiat_payment` instead when the customer has NOT deposited yet and needs payment
        instructions to send the money.
      required: [ type, account_id ]
      properties:
        type:
          {
            type: string,
            const: account,
            description: "Discriminator for an own-account source; always the literal
              `account`."
          }
        account_id:
          {
            type: string,
            description: "The `ozav_acc_` id of the customer's own named fiat account."
          }
    PixPaymentInstructions:
      type: object
      description: Provider-neutral Pix payment instructions for a specific pay-in
        transaction.
      required: [ rail, intent_id ]
      properties:
        rail:
          type: string
          const: pix
          description: The payment rail; always `pix`.
        intent_id:
          type: string
          description: >-
            OZAV's provider-neutral id for the Pix pay-in intent. **Opaque -
            store it and echo it back, never parse it.** Unlike the `ozav_*`
            resource ids, this one carries no prefix and is a UUID today; the
            format is not part of the contract and may change. Use it to
            correlate the Pix payment with this transfer, not to infer anything
            about the intent.
        br_code:
          type: [ string, "null" ]
          description: Pix copy-and-paste BR Code for this specific transaction.
        qr_code_image:
          type: [ string, "null" ]
          description: QR code image payload, when available.
        expires_at:
          type: [ string, "null" ]
          format: date-time
          description: When the Pix instructions expire.
    TransferSourceFiatPayment:
      type: object
      description: A fiat payment the customer sends in (the FX / on-ramp source leg).
      required: [ type, currency ]
      properties:
        type:
          {
            type: string,
            const: fiat_payment,
            description: "Discriminator for a fiat-payment source; always the literal
              `fiat_payment`."
          }
        currency: { $ref: "#/components/schemas/Currency" }
        rail: { $ref: "#/components/schemas/Rail" }
        virtual_account_id:
          {
            type: string,
            description: "Optional named virtual-account id to receive the payment into
              (roadmap; omit for the standard flow)."
          }
        payment_instructions:
          readOnly: true
          allOf:
            - $ref: "#/components/schemas/PixPaymentInstructions"
          description: >
            Returned for pay-in transfers after OZAV creates
            transaction-specific payment instructions. Clients display the Pix
            BR Code / QR to the payer; callers do not submit this field.
    TransferSourceWallet:
      type: object
      description: A managed wallet as the source of funds (off-ramp / wallet transfer).
      required: [ type, wallet_id ]
      properties:
        type:
          {
            type: string,
            const: wallet,
            description: "Discriminator for a wallet source; always the literal `wallet`."
          }
        wallet_id: { type: string, description: "The `ozav_wal_` id of the source wallet." }
        currency: { $ref: "#/components/schemas/Currency" }
        chain:
          type: string
          description: >-
            The network the funds leave from, when the wallet serves more than
            one. Same rule as on a wallet destination: with the FX rate lock it
            is what the hedge follows, and an absent network refuses the lock
            instead of guessing.
    TransferSourceCryptoPayment:
      type: object
      description: An inbound crypto deposit as the source (crypto receive).
      required: [ type, deposit_address_id ]
      properties:
        type:
          {
            type: string,
            const: crypto_payment,
            description: "Discriminator for a crypto-payment source; always the literal
              `crypto_payment`."
          }
        deposit_address_id:
          {
            type: string,
            description: "The id of the crypto deposit address the funds arrive at."
          }
    TransferDestination:
      description: Where a transfer's funds go. Discriminated by `type`; the value
        selects the flow together with the source.
      oneOf:
        - $ref: "#/components/schemas/TransferDestinationPayout"
        - $ref: "#/components/schemas/TransferDestinationWallet"
        - $ref: "#/components/schemas/TransferDestinationCryptoAddress"
        - $ref: "#/components/schemas/TransferDestinationAccount"
      discriminator:
        propertyName: type
        mapping:
          payout_destination: "#/components/schemas/TransferDestinationPayout"
          wallet: "#/components/schemas/TransferDestinationWallet"
          crypto_address: "#/components/schemas/TransferDestinationCryptoAddress"
          account: "#/components/schemas/TransferDestinationAccount"
    TransferDestinationPayout:
      type: object
      description: A registered payout destination as the target (FX / off-ramp
        destination leg).
      required: [ type, payout_destination_id ]
      properties:
        type:
          {
            type: string,
            const: payout_destination,
            description: "Discriminator for a payout-destination target; always the literal
              `payout_destination`."
          }
        payout_destination_id:
          {
            type: string,
            description: "The `ozav_pd_` id of the registered payout destination."
          }
    TransferDestinationAccount:
      type: object
      description: |
        The customer's OWN named fiat account (`ozav_acc_`) as the target of an off-ramp: the
        stablecoin is sold and the fiat is credited to that account, where it stays until the
        customer moves it. Available for USD, EUR and GBP accounts that are active and known to
        the account's institution; the quote's target currency must be the account's currency.

        Choose this when the customer wants to hold a fiat balance rather than pay a third
        party; choose `payout_destination` to pay out to a registered external account.
      required: [ type, account_id ]
      properties:
        type:
          {
            type: string,
            const: account,
            description: "Discriminator for an own-account target; always the literal
              `account`."
          }
        account_id:
          {
            type: string,
            description: "The `ozav_acc_` id of the customer's own named fiat account."
          }
    TransferDestinationWallet:
      type: object
      description: A managed wallet as the target (on-ramp / wallet transfer).
      required: [ type, wallet_id ]
      properties:
        type:
          {
            type: string,
            const: wallet,
            description: "Discriminator for a wallet target; always the literal `wallet`."
          }
        wallet_id: { type: string, description: "The `ozav_wal_` id of the destination wallet." }
        chain:
          type: string
          description: >-
            The network the credit lands on, when the wallet serves more than
            one.


            🔴 **Optional in general, but effectively REQUIRED when you use the
            FX rate lock.** The hedge OZAV buys is withdrawn to treasury on the
            SAME network as the delivery - buying on one and delivering on
            another would leave a crossing for someone to resolve with the
            exposure already open. With no network supplied there is nothing to
            follow, so the lock is **refused** (`facility.reason =
            sem_destino_de_tesouraria`) rather than guessed. The transfer itself
            still proceeds; only the lock is lost.


            ⚠️ The refusal can also mean the pair itself has no registered
            treasury destination (`USDC:tron`, for instance, is not a pair the
            counterparty sustains). The message names the `asset:network` pair,
            so read it before assuming the field is at fault.
    TransferDestinationCryptoAddress:
      type: object
      required: [ type, address, network, currency ]
      description: External crypto destination; triggers wallet screening and
        travel-rule requirements by policy.
      properties:
        type:
          {
            type: string,
            const: crypto_address,
            description: "Discriminator for an external crypto-address target; always the
              literal `crypto_address`."
          }
        address:
          {
            type: string,
            description: "The external blockchain address to send to; must be valid on
              `network`."
          }
        network: { $ref: "#/components/schemas/Network" }
        currency: { $ref: "#/components/schemas/Currency" }
    PayoutDocumentUpload:
      type: object
      description: |
        The supporting document for a payout, sent inline as base64.

        The bytes travel in the request rather than through a signed upload URL
        because this is one small file per payout, and a single call leaves no
        window in which the document is registered and its bytes are absent,
        a state that reads as "sent" and is not.
      additionalProperties: false
      required: [ file_name, content_type, content_base64 ]
      properties:
        file_name:
          type: string
          minLength: 1
          maxLength: 255
          description: Name shown to compliance reviewers.
          example: invoice-2026-08.pdf
        content_type:
          type: string
          description: |
            PDF only. The receiving institution named PDF as the format for the
            invoice, and a file its compliance review cannot accept comes back as
            a request for information days later, by email. Refusing here is an
            immediate message to whoever still has the file in hand.
          enum: [ application/pdf ]
        content_base64:
          type: string
          minLength: 1
          description: |
            The file, base64-encoded, without a `data:` prefix. Maximum 10 MB
            decoded. Content that does not decode, or decodes to zero bytes, is
            refused with `400 DOCUMENT_CONTENT_INVALID` before anything is stored.
          example: JVBERi0xLjQKJeLjz9MK

    PayoutDocument:
      type: object
      description: The stored supporting document for a payout.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [ payout_document ] }
        transfer_id:
          type: string
          description: The transfer this document supports (`ozav_tr_...`).
        file_name: { type: string }
        content_type: { type: string }
        size_bytes: { type: integer, description: Decoded size. }
        provider_status:
          type: string
          enum: [ enviado, recusado, sem_transacao, sem_codigo ]
          description: |
            What happened when the document was forwarded to the receiving
            institution.

            **Absent means it has not been attempted yet**, which is always the
            case on upload: forwarding happens only after the payment order
            exists, because the document attaches to a transaction that does not
            exist while you are uploading. Absence is not a failure, and it is
            not a promise of success either. It is the third state, and
            collapsing it into either of the other two is what makes a payout
            that is quietly waiting on a document look like one that settled.

    TravelRule:
      type: object
      description: |
        Originator and beneficiary information for a crypto send, required by BCB
        Resolution 520/2025 when value leaves custody to an external address.

        **Read the "Required for an external crypto send" list below before building
        this object.** The fields marked required *there* are not the same as the ones
        marked required *here*: this schema is shared with paths where Travel Rule is
        supplementary, so it stays permissive. The mandatory set is enforced on
        `destination.type = crypto_address`, and a request that misses any of it is
        refused with `422 TRAVEL_RULE_REQUIRED` and a `details.missing_fields` array
        naming each one.

        ### Required for an external crypto send

        | field | rule |
        |---|---|
        | `originator.name` | non-blank |
        | `originator.identity_documents[].type` | at least one entry must be accepted for the customer's country - see the table below |
        | `originator.identity_documents[].number` | non-blank |
        | `originator.identity_documents[].issuing_country` | required on **every** entry |
        | `originator.address.street_line_1` | non-blank (`street` and `line1` are accepted aliases) |
        | `originator.address.city` | non-blank |
        | `originator.address.country` | non-blank |
        | `beneficiary.name` | non-blank |
        | `beneficiary.wallet_type` | `external` or `self_custodied` (`hosted` is rejected on this path) |
        | `beneficiary.wallet_attested_ownership_at` | ISO-8601 timestamp of when the holder attested ownership; must not be in the future |
        | `beneficiary.is_self` | boolean; `true` when the destination belongs to your own customer |

        #### Accepted originator identity documents, by country

        The requirement follows the customer's country of residence as held on the
        customer record - **not** the country you send inside `travel_rule`, which is
        stored but never selects the regime. A country not listed below uses the
        default set.

        To be precise about where that country comes from: you declare it when you
        create the customer (`individual.tax_id.country`, or the address), and you can
        correct it later with `PATCH /customers/{id}`. Both paths refuse a jurisdiction
        OZAV does not serve. So the regime is not selectable *per request*, but it does
        follow what you declared for that customer - keep it accurate.

        | country | accepted `identity_documents[].type` |
        |---|---|
        | `BR` | `cpf`, `cnpj` |
        | `NG` | `bvn`, `nin`, `passport`, `national_id` |
        | *(default)* | `passport`, `national_id` |

        Until 30 Aug 2026 this gate required `cpf` or `cnpj` from every originator,
        in both environments, which made an external send impossible for a customer
        outside Brazil. If your customer's country is not listed and the default set
        does not fit its national identifiers, tell us and it gets modelled - the
        list is data, not code.

        A destination that is an OZAV wallet id (`ozav_wal_...`) is exempt: the value
        stays in custody and both sides are already verified.

        Values inside `originator` and `beneficiary` must be plain strings - do not
        embed internal or third-party references.
      properties:
        originator:
          $ref: "#/components/schemas/TravelRuleParty"
        beneficiary:
          $ref: "#/components/schemas/TravelRuleParty"

    TravelRuleParty:
      type: object
      description: |
        One side of a Travel Rule record. `required` here lists only what every path
        needs; see `TravelRule` for the stricter set an external crypto send enforces.
      required: [ name ]
      properties:
        name:
          type: string
          description: Full legal name of the party, as it appears on their verified
            identity.
        account:
          type: string
          description: The wallet address or account reference for this party.
        identity_documents:
          type: array
          description: |
            Identity documents for this party. On an external crypto send the originator
            must carry at least one entry whose type is accepted for the customer's
            country (see "Accepted originator identity documents, by country").
            Accepted under the legacy name
            `identifying_information` as well; both normalize to the same record.
          items:
            type: object
            required: [ type, number, issuing_country ]
            properties:
              type:
                type: string
                description: |
                  Document kind, lowercase. Which kinds satisfy the originator
                  requirement depends on the customer's country - see "Accepted
                  originator identity documents, by country" above. Other kinds are
                  accepted and stored but do not satisfy it on their own.
                example: cpf
              number:
                type: string
                description: The document number. Non-blank.
                example: "11144477735"
              issuing_country:
                type: string
                description: ISO 3166-1 alpha-2 country that issued the document.
                example: BR
        address:
          type: object
          description: |
            Postal address. Required for the originator on an external crypto send.
            `street_line_1` is the canonical key; `street` and `line1` are accepted aliases.
          properties:
            street_line_1: { type: string }
            street_line_2: { type: string }
            city: { type: string }
            state: { type: string }
            postal_code: { type: string }
            country:
              type: string
              description: ISO 3166-1 alpha-2.
              example: BR
        wallet_type:
          type: string
          enum: [ hosted, external, self_custodied ]
          description: |
            How the party's wallet is held. On an external crypto send the beneficiary
            must be `external` or `self_custodied`; `hosted` is rejected there.
        wallet_attested_ownership_at:
          type: string
          format: date-time
          description: When the holder attested ownership of the wallet.
        is_self:
          type: boolean
          description: |
            True when this party is your own customer (a send to their own external
            wallet) rather than a third party.

    Transfer:
      type: object
      description: A money-movement resource. One resource serves every flow; the
        `(source.type, destination.type)` pair selects it.
      required: [ id, object, status, source, destination, amounts ]
      properties:
        id:
          {
            type: string,
            description: "Opaque transfer identifier, prefixed `ozav_tr_`."
          }
        object:
          {
            type: string,
            const: transfer,
            description: "Resource type discriminator; always the literal `transfer`."
          }
        customer_id:
          {
            type: string,
            description: "The `ozav_cus_` id of the customer this transfer is for."
          }
        quote_id:
          {
            type: string,
            description: "The `ozav_q_` id of the executed quote, when the transfer
              references one."
          }
        status: { $ref: "#/components/schemas/TransferStatus" }
        approval_id:
          type: string
          description: >
            Present only while `status` is `awaiting_approval`: the `ozav_apr_`
            id your approver keys decide at `POST
            /approvals/{approval_id}/approve`. Absent once decided - the trail
            lives on the approval, not here.
        status_detail:
          type: object
          description: Optional provider-neutral detail explaining the current status.
            Present for a transfer that needs OZAV reconciliation, and for one
            that ended badly (`failed`, `cancelled`, `expired`) once the reason
            was recorded; `code` is a catalog error code you can branch on.
            Absent when no reason was recorded, which is the case for transfers
            created before 2026-09-06; treat absence as "not stated", never as
            "no reason". It never contains provider names, native rail error
            payloads, or a compliance verdict.
          properties:
            code: { type: string, description: "Machine-readable OZAV status detail code." }
            message: { type: string, description: "Human-readable provider-neutral status detail." }
            retryable:
              {
                type: boolean,
                description: "Whether the client should retry the same action without OZAV
                  intervention."
              }
        source: { $ref: "#/components/schemas/TransferSource" }
        destination: { $ref: "#/components/schemas/TransferDestination" }
        source_leg: { $ref: "#/components/schemas/SettlementLeg" }
        destination_leg: { $ref: "#/components/schemas/SettlementLeg" }
        amounts:
          type: object
          description: The amounts moved and the FX rate applied to this transfer.
          properties:
            source: { $ref: "#/components/schemas/MonetaryAmount" }
            target: { $ref: "#/components/schemas/MonetaryAmount" }
            rate:
              {
                type: string,
                description: "The applied FX rate as a decimal string (target units per source
                  unit). Absent for a same-currency move."
              }
        exchange_conditions: { $ref: "#/components/schemas/ExchangeConditions" }
        travel_rule: { $ref: "#/components/schemas/TravelRule" }
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the transfer was created (RFC 3339 / ISO 8601, UTC)."
          }
        updated_at:
          {
            type: string,
            format: date-time,
            description: "When the transfer was last updated (RFC 3339 / ISO 8601, UTC)."
          }
        completed_at:
          {
            type: string,
            format: date-time,
            description: "When the transfer reached a terminal state (RFC 3339 / ISO 8601,
              UTC). Present once `status` is terminal."
          }
        metadata: { $ref: "#/components/schemas/Metadata" }
        facility: { $ref: "#/components/schemas/FacilityOutcome" }
    FacilityOutcome:
      type: object
      description: >
        **What happened to the FX rate lock on this transfer.**


        Present on the `201` of a create that sent `facility_terms_accepted:
        true`, **on BRL/Pix flows**. A transfer created without the lock has no
        `facility` key at all, and its body is byte-for-byte what it has always
        been.


        ⚠️ **Two limits worth knowing before you build on it.**


        **It is a create-time outcome, not transfer state.** `GET
        /transfers/{id}` does not return it, and neither do the `transfer.*`
        webhook events after the first one. If you need it later, store it when
        you receive the `201`.


        **The lock applies to BRL/Pix on-ramp and off-ramp only.** The fields
        are accepted on every combination, but on a `crypto_address`
        destination, a wallet-to-wallet move, or the BRS redemption corridor
        they have no effect and no `facility` block comes back. Read the absence
        of the block on those flows as "no lock", not as "locked".


        🔴 **Read `locked` before you present a price to the payer.** A lock can
        be REQUESTED and still not happen, and those cases return `201` on
        purpose: the Pix charge is valid and the customer will pay it, so
        refusing would lose the operation for a condition that only costs the
        lock. The consequence is that this block is the only thing standing
        between "the price is fixed" and "the price is whatever the market does
        before you pay".


        | `state` | `locked` | What it means | What to do | |---|---|---|---| |
        `locked` | `true` | The rate is fixed until `deadline_at`. | Deliver
        before the deadline. Missing it triggers the schedule's penalty. | |
        `not_locked` | `false` | The lock did not happen and there is nothing to
        correct - typically the shared ceiling had no room, or a withdrawal was
        covered by the balance already delivered. The transfer is valid. |
        Nothing, but the conversion uses the rate at execution time. Tell
        whoever is paying. | | `refused` | `false` | The lock was refused by
        account state - conditions changed between the quote and the acceptance.
        | Read `reason`, fix it, and accept again. | | `undetermined` | `false`
        | 🔴 The hedge MAY have executed; nothing was recorded as locked. | Do
        NOT retry and do NOT treat as failed. Reconcile with `GET
        /transfers/{id}`. |
      readOnly: true
      required: [ locked, state ]
      properties:
        locked:
          type: boolean
          description: "Whether the rate is actually fixed. The one field a payer's
            decision hangs on."
        state:
          type: string
          enum: [ locked, not_locked, refused, undetermined ]
          description: >-
            The outcome. ⚠️ `not_locked` and `refused` are NOT synonyms and must
            not be collapsed: `refused` means the caller has something to
            correct, `not_locked` means there is nothing to do and the operation
            proceeds unlocked.
        reason:
          type: string
          description: >-
            Machine-readable slug for why the lock did not happen. Absent when
            `locked` is `true`. **This is the stable discriminator - branch on
            it, never on `message`.** Same vocabulary as `details.reason` on
            `422 FACILITY_LOCK_UNAVAILABLE`, so one mapping serves both the
            refusal and the degraded path.
        message:
          type: string
          description: >-
            Human-readable explanation. May change without notice, and is
            currently served in Portuguese on some paths. Never parse it.
        deadline_at:
          type: string
          format: date-time
          description: >-
            When the locked rate expires. Present only when `locked` is `true`.
            This is the deadline as RECORDED, not one recalculated client-side -
            a second, locally computed deadline is not the one a charge would be
            based on.
        amount:
          {
            type: string,
            description: "The amount locked. Present only when `locked` is `true`."
          }
        currency:
          {
            type: string,
            description: "The currency of `amount`. Present only when `locked` is `true`."
          }
    AccountPayoutCreateRequest:
      type: object
      additionalProperties: false
      description: |
        A payment out of the customer's own named account, in the account's
        currency, to one of the customer's registered payout destinations.
      required: [ amount, destination, reference ]
      properties:
        amount:
          type: string
          description: Decimal string with exactly two fractional digits (`"50.00"`).
            Never a float.
          pattern: "^(0|[1-9]\\d{0,14})\\.\\d{2}$"
          examples: [ "50.00" ]
        destination:
          type: object
          additionalProperties: false
          required: [ type, payout_destination_id ]
          properties:
            type: { type: string, const: payout_destination }
            payout_destination_id:
              type: string
              description: The `ozav_pd_` id of a destination registered for the SAME
                customer, in the same currency as the account.
        reference:
          type: string
          description: |
            1-35 characters (letters, digits, space, `.`, `_`, `/`, `-`). Your
            idempotency lever: the same `reference` with the same `amount` on the
            same account is the same payout. Also sent to the receiving bank as
            remittance information.
          pattern: "^[A-Za-z0-9 ._/-]{1,35}$"
        purpose:
          type: string
          maxLength: 140
          description: Optional purpose code or short description, passed to the payment
            institution when the rail carries one.
        metadata: { $ref: "#/components/schemas/Metadata" }
    AccountPayout:
      type: object
      additionalProperties: false
      description: |
        A payment sent out of a customer's named account. `status` follows the
        payment institution: `accepted` means the order was taken, not that the
        money arrived; `settled` means it did; `rejected` is final; `pending`
        means the institution has not classified the outcome yet - read the
        account's `/movements` rather than assuming either way.
      required:
        [
          id,
          object,
          account_id,
          destination,
          amount,
          currency,
          reference,
          status,
          metadata
        ]
      properties:
        id:
          {
            type: string,
            description: "Opaque payout id, prefixed `ozav_apo_`. Stable for the same
              `reference` and `amount`."
          }
        object: { type: string, const: account_payout }
        account_id: { type: string, description: "The `ozav_acc_` id the money left." }
        destination:
          type: object
          required: [ type, payout_destination_id ]
          properties:
            type: { type: string, const: payout_destination }
            payout_destination_id: { type: string }
        amount: { type: string, description: Decimal string with two fractional digits. }
        currency: { $ref: "#/components/schemas/Currency" }
        reference: { type: string }
        status:
          type: string
          enum: [ pending, accepted, settled, rejected ]
        purpose: { type: string }
        metadata: { $ref: "#/components/schemas/Metadata" }
    AccountCreateRequest:
      type: object
      additionalProperties: false
      description: |
        Request a named fiat receiving account for a customer whose supporting
        documents are already on file. `approved` is the verification result and
        is NOT sufficient on its own: this endpoint reads the documents, and on
        `live` it refuses with `422 ACCOUNT_PROFILE_INCOMPLETE` naming each one
        still missing. Send them with `POST /customers/{customer_id}/documents`
        first. The sandbox does not enforce this, so an account opening there
        proves the request is well-formed, never that the customer is ready.

        The URL stays the same across currencies, countries, rails, and
        providers; availability is selected by the account rail matrix below.
      # Os corredores de conta nominal que a OZAV atende. A URL nao muda entre eles: moeda,
      # pais e trilho sao parametros, e o roteamento acontece do nosso lado.
      #
      # ⚠️ SEM ROTULO DE ESTADO, e de proposito. Esta lista descreve a OFERTA, nao o cronograma de
      # implantacao interno da OZAV: palavra de rollout em spec publico e informacao nossa vazando
      # para quem integra, e nao muda nada do que a pessoa escreve.
      #
      # O que um integrador precisa saber ele descobre no lugar certo: um pedido de corredor que a
      # OZAV ainda nao possa servir responde erro NOMEADO, na hora, em vez de a pessoa ler um
      # rotulo e programar em cima dele. Estado vive no comportamento, nao na documentacao.
      x-ozav-account-rail-availability:
        - { currency: BRL, country: BR, rail: pix }
        - { currency: USD, country: US, rail: ach }
        - { currency: USD, country: US, rail: fedwire }
        - { currency: EUR, country: EU, rail: sepa }
        - { currency: GBP, country: GB, rail: faster_payments }
        - { currency: MXN, country: MX, rail: spei }
        - { currency: COP, country: CO, rail: co_transfer }
        - { currency: USD, country: US, rail: swift }
        - { currency: USD, country: GB, rail: swift }
        # ARS/cvu e CLP/cl_transfer NAO aparecem aqui, e os dois trilhos existem no codigo.
        # `AccountCreateRequest.currency` e um enum de 6 moedas e `rail` faz $ref de `Rail`, um enum
        # de 8 trilhos compartilhado com transfers e payout destinations. Anunciar um corredor cujos
        # valores o proprio request rejeita seria pior que nao anuncia-lo. Publica-se quando houver
        # provedor, e o vocabulario se decide naquele momento, nao antes.
        #
        # USD/GB/swift SAIU daqui em 09-ago, e a linha continua existindo no codigo.
        #
        # Ela descreve uma conta de funding DA PROPRIA OZAV, nao um corredor que um cliente possa
        # pedir: o trilho `swift` e o unico sem pais fixado pelo schema, entao a coordenada dita o
        # pais. Publicar aqui transformava um fato da nossa tesouraria em oferta.
        #
        # No codigo a linha e `axis: 'treasury_only'` em ACCOUNT_RAIL_MATRIX, e nao pode ser
        # removida de la: sem ela `accountDescriptorForRow` lanca ao ler a conta, e a conta passa a
        # existir sem poder ser mostrada. `check-v2-account-rail-parity.mjs` mantem as duas pontas
        # honestas: linha `treasury_only` que aparecer aqui reprova.
      required: [ customer_id, account_type, currency, country, rail ]
      properties:
        customer_id:
          type: string
          description: The `ozav_cus_` id of the approved customer receiving the account.
        account_type:
          type: string
          const: named_fiat_account
          description: Account kind. API v2 exposes named fiat accounts here.
        currency:
          type: string
          enum: [ BRL, USD, EUR, GBP, MXN, COP ]
          description: Account currency. BRL is the first sandbox-active fiat account
            currency.
        country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: Account country or region code. BR is active for BRL/Pix sandbox
            accounts.
        rail:
          $ref: "#/components/schemas/Rail"
          description: Receiving rail for this account.
        metadata: { $ref: "#/components/schemas/Metadata" }
    AccountStatus:
      type: string
      description: |
        Public lifecycle state for a named account:
        - `pending` - opening is in progress or waiting for review.
        - `active` - receiving instructions can be shown to the customer.
        - `suspended` - account is blocked or deactivated.
        - `failed` - opening did not complete.
      enum: [ pending, active, suspended, failed ]
    AccountPixInstructions:
      type: object
      additionalProperties: false
      required: [ key, key_type ]
      properties:
        key:
          type: string
          description: Pix key customers can use to receive BRL into this account.
        key_type:
          type: string
          enum: [ cpf, cnpj, email, phone, random, evp ]
          description: What kind of Pix key `key` is. Payer apps validate the key against
            its type, so publish the pair together - a correct key presented as
            the wrong type is refused.
    AccountBankInstructions:
      type: object
      additionalProperties: false
      properties:
        bank_name:
          type: string
          description: Bank or payment institution name, when assigned.
        branch:
          type: string
          description: Branch number, when assigned.
        account_number:
          type: string
          description: Account number, including check digit when assigned.
        routing_number:
          type: string
          description: Domestic routing number for supported rails such as ACH.
        iban:
          type: string
          description: IBAN for supported rails such as SEPA.
        bic:
          type: string
          description: BIC or SWIFT code, when assigned.
        sort_code:
          type: string
          description: |
            UK sort code (six digits, usually shown as `00-00-00`). Present on `faster_payments`
            accounts.
        clabe:
          type: string
          description: |
            Mexican CLABE - 18 digits, including its own check digit. Present on `spei` accounts.
        bre_b_key:
          type: string
          description: |
            Colombian Bre-B key (the payer-facing alias). Present on `co_transfer` accounts.
        account_type:
          type: string
          enum: [ payment ]
          description: The account category to state on a transfer form. `payment` is a
            payment account (conta de pagamento), which some payer flows require
            to be selected explicitly instead of checking or savings.
    AccountReceivingInstructions:
      type: object
      additionalProperties: false
      description: Rail-specific instructions to receive funds into this customer account.
      properties:
        pix: { $ref: "#/components/schemas/AccountPixInstructions" }
        bank_account: { $ref: "#/components/schemas/AccountBankInstructions" }
    PayoutDestinationCapabilities:
      type: object
      additionalProperties: false
      required: [ receive ]
      properties:
        receive:
          type: boolean
          description: |
            Whether this destination is registered with the settlement venue.

            Read this, not `status`. A destination is `active` from the moment you create it, and
            `status` never says anything about readiness - `status: active` with
            `capabilities.receive: false` is a normal, expected state.

            **What it gates, precisely:** a payout sourced from a wallet
            (`wallet` -> `payout_destination`) requires the venue registration, and is refused with
            `422 PROVISIONING_REQUIRED` while this is `false`. A **local BRL/Pix** destination
            settles on the domestic rail and never gets a venue reference, so it stays `false` and
            is still usable from a fiat pay-in (`fiat_payment` -> `payout_destination`).

            So: `false` means "not venue-registered", not "unusable". Poll
            `GET /payout-destinations/{id}` if you need the wallet-sourced route.
    AccountCapabilities:
      type: object
      additionalProperties: false
      required: [ receive, send ]
      properties:
        receive:
          type: boolean
          description: Whether this account can currently receive on its configured rail.
        send:
          type: boolean
          description: Whether this account can currently initiate sends on its configured
            rail through API v2.
    AccountMovement:
      type: object
      additionalProperties: false
      required: [ id, object, direction, amount, currency, occurred_at ]
      description: One credit into a named fiat account, as the account's institution
        reports it. Amounts are decimal strings, never JSON numbers.
      properties:
        id:
          type: string
          description: The institution's own identifier for the entry. Stable across
            reads; use it to de-duplicate.
        object:
          type: string
          const: account_movement
        direction:
          type: string
          enum: [ credit ]
          description: Only credits are listed today - money that arrived in the account.
        amount:
          type: string
          pattern: "^-?\\d+(\\.\\d+)?$"
          description: The amount the payer sent, as a decimal string in `currency`.
        currency:
          $ref: "#/components/schemas/Currency"
        occurred_at:
          type: string
          format: date-time
          description: When the institution booked the credit (RFC 3339, UTC).
        reference:
          type: string
          description: The free text the payer sent with the transfer, when the rail
            carries one. Absent means the rail carried none.
        payer:
          type: object
          additionalProperties: false
          description: Who paid, when the institution reports it. Absent when it does not.
          properties:
            name: { type: string }
            document:
              type: string
              description: The payer's tax or registration document, as reported.
        received:
          type: object
          additionalProperties: false
          required: [ amount, currency ]
          description: The amount that actually landed, when the institution reports it
            separately from `amount` (international wires lose correspondent
            charges). Absent means the institution reports one figure only.
          properties:
            amount:
              type: string
              pattern: "^-?\\d+(\\.\\d+)?$"
            currency:
              $ref: "#/components/schemas/Currency"
    AccountBalance:
      type: object
      additionalProperties: false
      required: [ available, currency, as_of ]
      description: A balance reading mirrored from the account provider. Amounts are
        decimal strings in the account currency's own precision (two decimals
        for USD, EUR, GBP and BRL). Never a JSON number.
      properties:
        available:
          type: string
          pattern: "^-?\\d+(\\.\\d+)?$"
          description: The balance available at `as_of`, as a decimal string (`"965.00"`).
        currency:
          $ref: "#/components/schemas/Currency"
        as_of:
          type: string
          format: date-time
          description: When OZAV took this reading (RFC 3339, UTC). Readings refresh every
            few minutes; a payment received after this instant is not yet
            reflected.
    Account:
      type: object
      additionalProperties: false
      description: |
        A customer-owned named fiat account. The account id is OZAV-owned and
        remains stable across provider routing.

        `currency`, `country` and `rail` describe the account that was actually
        opened, which may differ from the values in your request. OZAV selects
        the banking route, and the receiving coordinates decide the rail: an
        account reachable by IBAN is not reachable by the same instructions as
        one reachable by a domestic account number. Read these three fields from
        the response rather than assuming your request was echoed back, and
        publish `receiving_instructions` to your payers exactly as returned.
      required:
        [
          id,
          object,
          customer_id,
          account_type,
          currency,
          country,
          rail,
          status,
          holder,
          receiving_instructions,
          capabilities,
          balance,
          created_at,
          updated_at
        ]
      properties:
        id:
          type: string
          description: Opaque account identifier, prefixed `ozav_acc_`.
        object:
          type: string
          const: account
          description: Resource type discriminator; always the literal `account`.
        customer_id:
          type: string
          description: The `ozav_cus_` id of the owning customer.
        account_type:
          type: string
          const: named_fiat_account
          description: The only account kind v2 exposes today - a fiat account in the
            customer's own name. Present so a future kind is additive rather
            than a breaking change.
        currency:
          $ref: "#/components/schemas/Currency"
        country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO 3166-1 alpha-2 country of the account that was actually opened.
            OZAV picks the banking route, so read this back rather than assuming
            your request was echoed - see the note on this schema.
        rail:
          $ref: "#/components/schemas/Rail"
        status: { $ref: "#/components/schemas/AccountStatus" }
        holder:
          type: object
          additionalProperties: false
          required: [ name, type ]
          description: Who the account is in the name of. Taken from the customer record,
            not from this request - the account cannot be opened in a name the
            customer has not been verified under.
          properties:
            name: { type: string, description: The legal holder name. }
            type:
              type: string
              enum: [ individual, business ]
              description: Mirrors the owning customer's type; a business customer's account
                is held in the company name, an individual's in the person's.
        receiving_instructions: { $ref: "#/components/schemas/AccountReceivingInstructions" }
        capabilities: { $ref: "#/components/schemas/AccountCapabilities" }
        balance:
          description: |
            The balance OZAV last read from the account provider, or `null` when OZAV has never
            read one for this account. Always present. `null` never means zero: it means no
            reading exists yet (a freshly opened account, or one whose provider does not expose a
            balance). When present, `as_of` says when the reading was taken - the number is a
            mirror, not a live query, and a payment received after `as_of` is not reflected.
          oneOf:
            - $ref: "#/components/schemas/AccountBalance"
            - type: "null"
        created_at:
          type: string
          format: date-time
          description: When the account record was created (RFC 3339, UTC). Not when it
            became usable - `status` and `capabilities` decide that.
        updated_at:
          type: string
          format: date-time
          description: When the account record last changed (RFC 3339, UTC).
        pending_action:
          type: object
          additionalProperties: false
          required: [ type ]
          description: |
            Present only while `status` is `pending`, and only when something is actually being
            waited on from the holder. `url` is omitted when the verification link cannot be
            verified as belonging to the account provider, in which case the action is still
            reported so the holder knows verification is outstanding.
          properties:
            type:
              type: string
              enum: [ complete_verification ]
              description: What is being waited on. `complete_verification` - the holder must
                finish a verification step, at `url` when one could be
                published.
            url:
              type: string
              format: uri
              description: Verification webview for the account holder. HTTPS, provider-owned
                host.
        failure_reason:
          type: string
          description: |
            Present only while `status` is `failed`, and only when a reason was recorded. Absent
            means we did not record one. It never means "a reason exists and we withheld it".

            A comma-separated list of codes. Never free text, and never a message written by the
            underlying institution. Every value is one of:

            - `billing_address`, `registered_address`, `company_documents`, `representatives`,
              `representative_address`, `representative_documents`, `time_in_business`,
              `tax_document`, `legal_name`: the holder still owes us this. Collect it and retry.
            - `counterparty_block`: the block is NOT the holder's paperwork. Sending more
              documents will not clear it. Contact us.
            - `other_requirement`: a reason was recorded that we could not map to a code above.
              When it appears alongside other codes, the rest of the list is NOT exhaustive.

            Branch on exact values, and treat an unrecognised value the way you treat
            `other_requirement`. The list grows. Every value is a member of
            `AccountFailureCode`, which carries the full enum.
        metadata: { $ref: "#/components/schemas/Metadata" }
    AccountFailureCode:
      type: string
      description: |
        One value of `Account.failure_reason`. That field carries a comma-separated list of these,
        so a value here is never the whole field.
      enum:
        [
          billing_address,
          registered_address,
          company_documents,
          representatives,
          representative_address,
          representative_documents,
          time_in_business,
          tax_document,
          legal_name,
          counterparty_block,
          other_requirement
        ]

    AccountPage:
      allOf:
        - $ref: "#/components/schemas/Page"
        - type: object
          properties:
            data:
              type: array
              description: >-
                The accounts on this page, newest first. Page through with
                `starting_after`; an empty array alongside `has_more` false
                means the end.
              items: { $ref: "#/components/schemas/Account" }
    AccountHolder:
      type: object
      description: The legal owner of a payout-destination account.
      required: [ name, type ]
      properties:
        name: { type: string, description: "The account holder's legal name." }
        type:
          type: string
          description: Whether the account holder is a natural person (`individual`) or a
            company (`business`).
          enum: [ individual, business ]
        tax_id: { $ref: "#/components/schemas/HolderTaxId" }
        address: { $ref: "#/components/schemas/Address" }
    PayoutDestinationDetailsPix:
      type: object
      description: BRL / PIX payout destination. Provide a reusable PIX key; do not
        provide a one-time Pix BR Code.
      required: [ pix_key ]
      properties:
        pix_key:
          {
            type: string,
            description: "A PIX key - CPF/CNPJ, email, phone number, or random (EVP) key."
          }
    PayoutDestinationDetailsUsBank:
      type: object
      description: USD / ach or fedwire.
      required: [ account_number, routing_number ]
      properties:
        account_number: { type: string, description: "The beneficiary bank account number." }
        routing_number:
          {
            type: string,
            description: "The 9-digit ABA routing number of the beneficiary bank."
          }
        account_type:
          type: string
          description: The bank account type - `checking` or `savings`.
          enum: [ checking, savings ]
    PayoutDestinationDetailsSepa:
      type: object
      description: EUR / sepa.
      required: [ iban ]
      properties:
        iban: { type: string, description: "The beneficiary IBAN." }
    PayoutDestinationDetailsSwift:
      type: object
      description: swift - beneficiary reached by SWIFT wire. This is a PAYOUT
        destination shape; it is not the account-opening corridor of the same
        name, and `Rail.x-ozav-rail-availability` does not describe it.
      required: [ account_number, bic ]
      properties:
        account_number: { type: string, description: "The beneficiary account number or IBAN." }
        bic: { type: string, description: "The beneficiary bank's SWIFT/BIC code." }
        intermediary:
          type: object
          description: Optional intermediary / correspondent bank details, when the route
            requires one.
          additionalProperties: true
        purpose_of_funds:
          {
            type: string,
            description: "Purpose-of-payment statement required for some cross-border
              corridors."
          }
    PayoutDestinationDetailsSpei:
      type: object
      description: MXN / spei.
      required: [ clabe ]
      properties:
        clabe:
          {
            type: string,
            description: "The 18-digit CLABE identifying the beneficiary account."
          }
    PayoutDestinationDetailsFasterPayments:
      type: object
      description: GBP / faster_payments - beneficiary reached by UK Faster Payments.
        This is a PAYOUT destination shape; it is not the account-opening
        corridor of the same name, and `Rail.x-ozav-rail-availability` does not
        describe it.
      required: [ account_number, sort_code ]
      properties:
        account_number: { type: string, description: "The 8-digit UK account number." }
        sort_code: { type: string, description: "The 6-digit UK sort code." }
    PayoutDestinationDetailsCoTransfer:
      type: object
      description: COP / co_transfer (sandbox_only - exercisable with a test key, not
        live yet; see Rail.x-ozav-rail-availability).
      required: [ account_number, bank_code ]
      properties:
        account_number: { type: string, description: "The beneficiary bank account number." }
        bank_code:
          {
            type: string,
            description: "The Colombian bank code identifying the beneficiary bank."
          }
        account_type: { type: string, description: "The account type (e.g. ahorros, corriente)." }
    PayoutDestinationDetails:
      description: Rail-specific beneficiary details; the applicable schema is
        selected by `rail`.
      oneOf:
        - $ref: "#/components/schemas/PayoutDestinationDetailsPix"
        - $ref: "#/components/schemas/PayoutDestinationDetailsUsBank"
        - $ref: "#/components/schemas/PayoutDestinationDetailsSepa"
        - $ref: "#/components/schemas/PayoutDestinationDetailsSwift"
        - $ref: "#/components/schemas/PayoutDestinationDetailsSpei"
        - $ref: "#/components/schemas/PayoutDestinationDetailsFasterPayments"
        - $ref: "#/components/schemas/PayoutDestinationDetailsCoTransfer"
    PayoutDestinationCreateRequest:
      type: object
      description: Register a payout destination for a customer on a specific rail.
        The `details` shape is selected by `rail`.
      required: [ customer_id, currency, rail, account_holder, details ]
      properties:
        customer_id:
          {
            type: string,
            description: "The `ozav_cus_` id of the customer this destination belongs to."
          }
        currency: { $ref: "#/components/schemas/Currency" }
        rail: { $ref: "#/components/schemas/Rail" }
        account_holder: { $ref: "#/components/schemas/AccountHolder" }
        details: { $ref: "#/components/schemas/PayoutDestinationDetails" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    PayoutDestination:
      type: object
      description: Where a customer's funds land off-ramp - a bank account, PIX key,
        or other rail endpoint.
      required: [ id, object, currency, rail, status ]
      properties:
        id:
          {
            type: string,
            description: "Opaque payout-destination identifier, prefixed `ozav_pd_`."
          }
        object:
          {
            type: string,
            const: payout_destination,
            description: "Resource type discriminator; always the literal
              `payout_destination`."
          }
        customer_id: { type: string, description: "The `ozav_cus_` id of the owning customer." }
        currency: { $ref: "#/components/schemas/Currency" }
        rail: { $ref: "#/components/schemas/Rail" }
        status:
          type: string
          description: |
            Whether the destination has been deactivated by you:
            - `active` - not deactivated.
            - `inactive` - deactivated; reactivate it before using it again.

            **`active` does not mean the destination is ready to receive a
            transfer.** A newly created destination is `active` immediately, but
            stays unusable until OZAV finishes registering it with the settlement
            rail. Using one before then is refused with
            `422 PROVISIONING_REQUIRED` ("Complete destination registration
            first"), and no field on this resource distinguishes the two states
            yet. Until one exists, treat `422 PROVISIONING_REQUIRED` as retryable
            rather than as a bad request, and do not gate your UI on `status`
            alone.
          enum: [ active, inactive ]
        capabilities: { $ref: "#/components/schemas/PayoutDestinationCapabilities" }
        account_holder: { $ref: "#/components/schemas/AccountHolder" }
        details: { $ref: "#/components/schemas/PayoutDestinationDetails" }
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the destination was registered (RFC 3339 / ISO 8601, UTC)."
          }
        metadata: { $ref: "#/components/schemas/Metadata" }
    CardCreateRequest:
      type: object
      additionalProperties: false
      required: [ customer_id ]
      properties:
        customer_id:
          type: string
          description: The `ozav_cus_` id of the approved customer receiving the card.
        type:
          type: string
          enum: [ virtual, physical ]
          default: virtual
          description: Card form factor. `physical` is reserved and currently returns
            `CARD_TYPE_NOT_AVAILABLE`.
        funding_type:
          type: string
          enum: [ prepaid_balance ]
          default: prepaid_balance
          description: Cards are funded from OZAV balances in this API version.
        brand_name:
          type: string
          maxLength: 32
          description: Optional brand label shown for white-label programs.
        replacement_for:
          type: string
          description: Existing `ozav_card_` id being replaced.
        replacement_reason:
          type: string
          enum: [ damaged, expired, lost, stolen ]
          description: Why the previous card is being replaced. Send it alongside
            `replacement_for`; on its own it describes nothing.
        spending_controls: { $ref: "#/components/schemas/CardSpendingControls" }
    CardSpendingControls:
      description: >-
        Per-card rules applied at authorisation time. A merchant category or
        country the acquirer does not send is treated as unknown: an allow list
        DECLINES it, because "only these" cannot be satisfied by a value nobody
        can name, while a block list permits it.
      type: object
      additionalProperties: false
      properties:
        allowed_categories:
          type: array
          items: { type: string }
          description: Merchant category codes (MCC) permitted on this card.
        blocked_categories:
          type: array
          items: { type: string }
          description: Merchant category codes (MCC) refused on this card.
        allowed_merchant_countries:
          type: array
          items: { type: string }
          description: ISO 3166-1 alpha-2 countries permitted. An authorization whose
            merchant country the acquirer does not send is DECLINED while this
            list is set - see the note on this schema.
        blocked_merchant_countries:
          type: array
          items: { type: string }
          description: ISO 3166-1 alpha-2 countries refused. An unknown merchant country
            is PERMITTED here, which is the opposite of the allow list.
        allowed_card_presences:
          type: array
          items: { type: string, enum: [ present, not_present ] }
          description: Restricts to card-present (terminal) or card-not-present (online)
            purchases. Leaving it unset permits both.
        blocked_card_presences:
          type: array
          items: { type: string, enum: [ present, not_present ] }
          description: Refuses card-present or card-not-present purchases. Setting
            `[not_present]` is the usual way to make a physical card useless
            online if stolen.
        spending_limits:
          type: array
          description: Rolling caps evaluated at authorization time. Each entry pairs an
            amount with the window it applies to; several entries apply together
            and the tightest one decides. Read the `amount` note - it is the one
            field on this API in MINOR UNITS.
          items:
            type: object
            additionalProperties: false
            required: [ amount, interval ]
            properties:
              amount: { type: integer, minimum: 1, description: Minor units. }
              interval:
                type: string
                description: >-
                  Reset period, aligned to the UTC calendar rather than a
                  rolling window: daily resets at 00:00 UTC, weekly on Monday,
                  monthly on the 1st, yearly on 1 January. all_time never
                  resets, and per_authorization applies to each transaction on
                  its own. Totals are built from authorised amounts, so a later
                  reversal does not return headroom within the same period.
                enum: [ per_authorization, daily, weekly, monthly, yearly, all_time ]
              categories:
                type: array
                items: { type: string }
                description: >-
                  Merchant category codes (MCC) this limit applies to. Omit to
                  cover all spend on the card. A transaction whose category the
                  acquirer does not send falls outside a scoped limit, so a
                  category budget never silently becomes a card-wide one.
    Card:
      type: object
      description: Canonical OZAV card object. Sensitive card data is accessed only
        through secure display sessions.
      required: [ id, object, customer_id, type, status, funding_type, created_at ]
      properties:
        id:
          type: string
          description: Opaque card identifier, prefixed `ozav_card_`.
        object:
          type: string
          const: card
          description: Resource type discriminator; always the literal `card`.
        customer_id:
          type: string
          description: The `ozav_cus_` id of the cardholder customer.
        type:
          type: string
          enum: [ virtual, physical ]
          description: Fixed at creation and never changes. A `physical` card additionally
            requires a shipping flow; a `virtual` card is usable as soon as it
            reaches `active`.
        status:
          type: string
          enum: [ pending, active, frozen, cancelled, expired ]
          description: |
            `pending` - created, not yet usable. `active` - authorizations are attempted.
            `frozen` - reversible refusal, set by `/freeze` and undone by `/unfreeze`.
            `cancelled` - TERMINAL, set by `/close`; there is no reopen, issue a replacement.
            `expired` - past `exp_month`/`exp_year`.

            `active` is not the same as "will approve". A card with no funded balance is `active`
            and declines every purchase with `Insufficient balance` - see `funding_type`.
        funding_type:
          type: string
          enum: [ prepaid_balance ]
          description: |
            How authorizations are funded. Only `prepaid_balance` exists today: the card spends a
            balance that must already be there.

            NOTE, because it decides whether you can pilot this: the v2 surface has no endpoint that
            LOADS that balance. Until funding is arranged with OZAV, a newly created card is `active`
            and declines every authorization for `Insufficient balance`.
        last4:
          type: [ string, "null" ]
          description: Last four digits of the card number, for display. `null` until the
            issuer has provisioned the card. The full number is never returned -
            use a display session.
        exp_month:
          type: [ integer, "null" ]
          description: Expiry month, 1-12. `null` until the issuer has provisioned the card.
        exp_year:
          type: [ integer, "null" ]
          description: Four-digit expiry year. `null` until the issuer has provisioned the
            card.
        brand_name:
          type: string
          description: The name printed on the card and shown to the cardholder. Defaults
            to `OZAV`.
        spending_controls: { $ref: "#/components/schemas/CardSpendingControls" }
        replacement_for:
          type: string
          description: When this card replaces another, the `ozav_card_` id of the one it
            replaced. Absent on a card that is not a replacement.
        replacement_reason:
          type: string
          enum: [ damaged, expired, lost, stolen ]
          description: Why the replaced card was reissued. Absent on a card that is not a
            replacement. Present only alongside `replacement_for`.
        created_at:
          type: string
          format: date-time
          description: When the card record was created (RFC 3339, UTC). Not when it
            became usable - that is `activated_at`.
        activated_at:
          type: [ string, "null" ]
          format: date-time
          description: When the card first became `active`. `null` while still `pending`.
            In the sandbox this is stamped at creation, so a test card skips the
            pending window.
        cancelled_at:
          type: [ string, "null" ]
          format: date-time
          description: When `/close` cancelled the card. `null` on a card that was never
            closed. Being terminal, this never returns to `null`.
    CardDisplaySession:
      type: object
      required: [ id, object, card_id, display_url, expires_at ]
      properties:
        id:
          {
            type: string,
            description: "Opaque display-session id, prefixed `ozav_cds_`."
          }
        object:
          type: string
          const: card_display_session
          description: Resource type discriminator; always the literal
            `card_display_session`.
        card_id:
          type: string
          description: The `ozav_card_` id whose sensitive data this session reveals.
        display_url:
          type: string
          format: uri
          description: OZAV-hosted short-lived display URL.
        expires_at:
          type: string
          format: date-time
          description: When `display_url` stops working (RFC 3339, UTC). Sessions are
            deliberately short-lived; mint a new one rather than storing this
            URL.
    CardActivityPage:
      allOf:
        - $ref: "#/components/schemas/Page"
        - type: object
          required: [ object, card_id, data, has_more, next_cursor ]
          properties:
            object:
              type: string
              const: list
              description: Resource type discriminator for the page envelope; always `list`.
                What the ITEMS are is in `item_object`.
            card_id:
              type: string
              description: The `ozav_card_` id this page belongs to; echoed so a page can be
                attributed without keeping the request around.
            data:
              type: array
              description: The activity rows. Their shape depends on `item_object` - read that
                first. Interchange figures and issuer identifiers are never
                included.
              items: { $ref: "#/components/schemas/CardActivity" }
            item_object:
              type: string
              enum: [ card_transaction, card_authorization ]
              description: |
                What the rows in `data` are, and they are NOT the same thing.
                `card_authorization` - the issuer's hold at purchase time; it can expire or be
                reversed and never settle. `card_transaction` - the settled movement.
                Summing both together double-counts the same purchase.
    CardActivity:
      type: object
      description: >-
        One row of card activity. `item_object` on the page says whether these
        are settled transactions or authorisation holds - the shape is the same,
        the meaning is not.


        Interchange figures and issuer identifiers are never included.
      required:
        [
          id,
          object,
          card_id,
          direction,
          amount,
          currency,
          status,
          merchant,
          created_at,
          settled_at
        ]
      properties:
        id:
          type: string
          description: Opaque activity id, prefixed `ozav_ctxn_`.
          example: ozav_ctxn_aa11bb22cc33dd44ee55ff6677889900
        object:
          type: string
          enum: [ card_transaction, card_authorization ]
          description: Matches the page's `item_object`.
        card_id:
          type: string
          description: The `ozav_card_` id this activity belongs to.
        direction:
          type: string
          enum: [ debit, credit ]
          description: >-
            `debit` for a purchase, `credit` for a refund. The amount is always
            POSITIVE and the direction says what it did - a signed amount is
            easy to mis-render and easy to sum wrongly.
        amount:
          type: string
          description: The amount charged to the card, in the CARD's currency, always
            positive.
          example: "50.00"
        currency:
          type: string
          description: >-
            The card's own currency. Always `USD` today: the card programme is
            USD-funded, so this is what the cardholder is actually charged. It
            is NOT the merchant's currency - see `presentment`.
          example: USD
        status:
          type: string
          enum: [ pending, authorized, settled, declined, reversed ]
        merchant:
          type: object
          required: [ name, category_code, country ]
          properties:
            name: { type: [ string, "null" ], example: "Cafe Lisboa" }
            category_code: { type: [ string, "null" ], description: "MCC.", example: "5812" }
            country:
              type: [ string, "null" ]
              description: >-
                Where the merchant is registered. NOT a reliable test for an
                international purchase - a merchant can be registered in one
                country and charge in another currency. Use `presentment`.
              example: "PT"
        presentment:
          type: object
          description: >-
            What the purchase cost in the MERCHANT's currency, when the issuer
            reports it.


            **This is how you identify an international transaction:**
            `presentment.currency !== currency`. Comparing `merchant.country`
            instead answers a different question and gets it wrong for a
            merchant registered in one country that bills in another.


            **The key is ABSENT when the issuer did not report it** - it is
            never filled in with the card currency. Absent means "we were not
            told", which has to stay distinguishable from a genuinely domestic
            purchase. So treat a missing `presentment` as unknown, not as
            domestic.


            `amount` is already scaled for the currency, so a zero-decimal
            currency comes back without decimals: JPY 5000 is `"5000"`, not
            `"50.00"`.
          required: [ amount, currency ]
          properties:
            amount: { type: string, example: "5000" }
            currency:
              type: string
              description: ISO 4217 code of the merchant's currency.
              example: JPY
        domestic:
          type: boolean
          description: >-
            **Deprecated and normally absent - do not build on it.** It has no
            writer today, so it is omitted from every row the current pipeline
            produces. Use `presentment` to tell a domestic purchase from an
            international one.
        created_at: { type: string, format: date-time }
        settled_at:
          type: [ string, "null" ]
          format: date-time
          description: Null while an authorisation has not settled.
    Wallet:
      type: object
      description: Balances and custody model for a customer's holdings. A 1:1
        projection - a customer has at most one wallet.
      required: [ id, object, custody_model, status ]
      properties:
        id:
          {
            type: string,
            description: "Opaque wallet identifier, prefixed `ozav_wal_`."
          }
        object:
          {
            type: string,
            const: wallet,
            description: "Resource type discriminator; always the literal `wallet`."
          }
        customer_id:
          {
            type: string,
            description: "The `ozav_cus_` id of the wallet's owning customer."
          }
        custody_model:
          type: string
          enum: [ managed, self_custody ]
          description: |
            `managed` wallets are held under OZAV-side custody arrangements;
            `self_custody` wallets are controlled by the client or their
            end-user, and OZAV only builds unsigned transactions.
        status:
          type: string
          description: |
            Whether the wallet is usable. A wallet is `active` only while its
            owning customer account is active; any other account state
            (pending, suspended, closed) projects to `inactive`.
          enum: [ active, inactive ]
        created_at:
          type: string
          format: date-time
          description: When the wallet was created (RFC 3339 / ISO 8601, UTC).
    Balance:
      type: object
      description: A wallet balance for one currency (and network, where applicable).
      required: [ amount, currency ]
      properties:
        amount: { $ref: "#/components/schemas/DecimalAmount" }
        currency: { $ref: "#/components/schemas/Currency" }
        network: { $ref: "#/components/schemas/Network" }
    OnchainBalance:
      type: object
      description: >
        The balance held AT a deposit address on one network, for one currency.
        NOT the ledger balance (see `Balance`): no in-flight outbound is
        subtracted, finality is not awaited, and an uncredited inbound deposit
        is included. Networks with a shared deposit address never appear here -
        their on-chain balance belongs to every customer at once.
      required: [ object, network, currency, address, amount ]
      properties:
        object:
          type: string
          enum: [ onchain_balance ]
          description: Resource type discriminator; always the literal `onchain_balance`.
            It marks this as the CHAIN reading, not the ledger `Balance`.
        network: { $ref: "#/components/schemas/Network" }
        currency: { $ref: "#/components/schemas/Currency" }
        address:
          type: string
          description: >
            The address this balance was read from - the same address
            `/deposit-addresses` returns for this network.
        amount:
          oneOf: [ { $ref: "#/components/schemas/DecimalAmount" }, { type: "null" } ]
          description: >
            `null` means the balance COULD NOT BE READ, never that it is zero. A
            zero here is a measured zero. Treat `null` as "unknown" and retry;
            treating it as `0` would report an empty wallet that may be full.
        unreadable_reason:
          type: string
          description: Present only when `amount` is `null`. Why the read failed.
    NftCollection:
      type: object
      description: |
        An ERC-721 / ERC-1155 collection this wallet registered. OZAV does not
        price it, does not credit it to your ledger balance, and does not include
        it in proof of reserves - so every transfer of a piece requires human
        approval from your quorum.
      properties:
        object: { type: string, enum: [ nft_collection ] }
        id:
          type: string
          description: The collection id (`ozav_nft_...`).
          example: ozav_nft_01J8Z9K2M3N4P5Q6R7S8T9U0V1
        chain: { type: string, example: ethereum }
        contract:
          type: string
          description: The collection contract address, lowercased.
          example: "0x1111111111111111111111111111111111111111"
        standard: { type: string, enum: [ erc721, erc1155 ] }
        name: { type: string, example: Acervo do Cliente }
        status:
          type: string
          enum: [ pending_signature, active, disabled ]
          description: >-
            `pending_signature` - registered, but OZAV's co-signer does not know
            it yet. `active` - the signed record exists. `disabled` - switched
            off through this API, which is not the same as revoked.
        created_at: { type: string, format: date-time }
    ClientToken:
      type: object
      description: |
        An ERC-20 this wallet registered beyond the assets OZAV prices and backs.
        OZAV does not price it, does not credit it to your ledger balance, and
        does not include it in proof of reserves - so every transfer of it
        requires human approval from your quorum.
      properties:
        object: { type: string, enum: [ client_token ] }
        id:
          type: string
          description: The token id (`ozav_tok_...`).
          example: ozav_tok_01J8Z9K2M3N4P5Q6R7S8T9U0V1
        chain: { type: string, example: ethereum }
        contract:
          type: string
          description: The ERC-20 contract address, lowercased.
          example: '0x1111111111111111111111111111111111111111'
        symbol: { type: string, example: MEUTOKEN }
        decimals: { type: integer, example: 18 }
        status:
          type: string
          enum: [ pending_signature, active, disabled ]
          description: |
            `pending_signature` - registered, but OZAV's co-signer does not know
            it yet, so no transfer of it can be signed. `active` - the signed
            record exists. `disabled` - switched off through this API, which is
            not the same as revoked.
        created_at: { type: string, format: date-time }
    WalletAddress:
      type: object
      description: >
        One entry of a managed wallet's address book - the destinations that
        leave without human review (`allowed`), plus the ones you proposed and
        OZAV has not decided (`pending_review`) or refused (`rejected`). The
        book IS the allowlist that the custody plane applies, with your labels.
      required: [ object, network, address, label, status, added_at, requested_at ]
      properties:
        object: { type: string, enum: [ wallet_address ] }
        network: { $ref: "#/components/schemas/Network" }
        address:
          type: string
          description: As the custody plane matches it (EVM lower-cased).
        label:
          oneOf: [ { type: string }, { type: "null" } ]
        status:
          type: string
          enum: [ allowed, pending_review, rejected ]
        added_at:
          oneOf: [ { type: string, format: date-time }, { type: "null" } ]
          description: When it entered the allowlist; `null` until approved.
        requested_at: { type: string, format: date-time }
    WalletAddressCreateRequest:
      type: object
      additionalProperties: false
      required: [ network, address ]
      properties:
        network: { $ref: "#/components/schemas/Network" }
        address:
          type: string
          description: The destination address, validated for its network. EVM addresses
            are stored lower-cased.
        label:
          type: string
          minLength: 1
          maxLength: 60
          description: Optional label, kept with the destination once approved.
    WalletFreeze:
      type: object
      description: >
        Whether sends from a managed wallet are paused. While `frozen` is true
        every send answers `409 WALLET_FROZEN` before anything is signed;
        deposits keep arriving and being credited.
      required: [ object, wallet_id, frozen, frozen_by, reason, since ]
      properties:
        object: { type: string, enum: [ wallet_freeze ] }
        wallet_id: { type: string }
        frozen: { type: boolean }
        frozen_by:
          oneOf: [ { type: string, enum: [ customer, ozav ] }, { type: "null" } ]
          description: >
            `customer`: your organization froze it and can unfreeze it. `ozav`:
            OZAV paused withdrawals (a reserve control); you cannot lift it -
            contact support. `null`: not frozen. When both exist, `customer` is
            reported: it is the one you can change.
        reason:
          oneOf: [ { type: string }, { type: "null" } ]
          description: For a customer freeze, the note you sent prefixed with the freezing
            key id.
        since:
          oneOf: [ { type: string, format: date-time }, { type: "null" } ]
        unfreeze_approval_id:
          type: string
          description: Present when an unfreeze is waiting for your approval quorum - the
            `ozav_apr_` to decide.
    WalletFreezeRequest:
      type: object
      additionalProperties: false
      properties:
        reason:
          type: string
          minLength: 1
          maxLength: 200
          description: Optional note, recorded with the freeze (or the lift) and carried
            in the event.
    Approval:
      type: object
      description: >
        A decision your approver keys owe: a managed-custody send held by your
        quorum, or a loosening of the quorum itself. Decisions are per API key;
        the key that requested the operation cannot decide it.
      required:
        [
          id,
          object,
          kind,
          status,
          resource,
          wallet_id,
          required_approvals,
          approvals_received,
          summary,
          decisions,
          requested_by_api_key_id,
          failure_reason,
          expires_at,
          created_at,
          decided_at,
          executed_at
        ]
      properties:
        id:
          type: string
          description: Opaque approval identifier, prefixed `ozav_apr_`.
        object:
          type: string
          enum: [ approval ]
        kind:
          type: string
          enum: [ wallet_transfer, wallet_policy, wallet_unfreeze ]
          description: >
            `wallet_transfer`: a custody send is waiting. `wallet_policy`: a
            loosening of the wallet's approval quorum is waiting.
            `wallet_unfreeze`: lifting a freeze you placed on a wallet whose
            quorum is on.
        status:
          type: string
          enum: [ pending, approved, rejected, expired, executed, failed ]
          description: >
            `pending`: waiting for decisions. `approved`: the quorum was reached
            (transient: the same request executes). `executed`: the held
            operation ran; see `result` on the deciding response. `failed`: the
            quorum was reached but the operation was refused on execution;
            `failure_reason` says why and nothing moved. `rejected`: an approver
            key said no. `expired`: 72 h passed without a quorum.
        resource:
          type: object
          required: [ type, id ]
          properties:
            type:
              type: string
              enum: [ transfer, wallet_transfer, wallet_policy, wallet_freeze ]
            id:
              type: string
              description: The `ozav_tr_`, `ozav_wtr_` or `ozav_wal_` id the approval is
                about.
        wallet_id:
          oneOf: [ { type: string }, { type: "null" } ]
        required_approvals: { type: integer }
        approvals_received: { type: integer }
        summary:
          type: object
          description: What is being approved, in brief (amount, currency, destination, or
            the requested quorum). Never the frozen request.
          additionalProperties: true
        decisions:
          type: array
          items:
            type: object
            required: [ api_key_id, decision, reason, decided_at ]
            properties:
              api_key_id: { type: string }
              decision: { type: string, enum: [ approve, reject ] }
              reason:
                oneOf: [ { type: string }, { type: "null" } ]
              decided_at: { type: string, format: date-time }
        requested_by_api_key_id:
          type: string
          description: The key that created the operation. It cannot approve or reject it.
        failure_reason:
          oneOf: [ { type: string }, { type: "null" } ]
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        decided_at:
          oneOf: [ { type: string, format: date-time }, { type: "null" } ]
        executed_at:
          oneOf: [ { type: string, format: date-time }, { type: "null" } ]
    ApprovalDecisionRequest:
      type: object
      additionalProperties: false
      properties:
        reason:
          type: string
          minLength: 1
          maxLength: 500
          description: Optional note recorded with the decision and, on a rejection,
            carried in `transfer.rejected`.
    WalletPolicy:
      type: object
      description: >
        What OZAV applies before signing a send from a managed wallet, and the
        changes to it still being decided. Read `limits.source` first: it says
        whether the numbers are a per-wallet signed record, your organization's
        defaults, or nothing at all (everything reviewed).
      required:
        [
          object,
          wallet_id,
          destinations,
          limits,
          approvals,
          pending_changes,
          updated_at
        ]
      properties:
        object:
          type: string
          enum: [ wallet_policy ]
          description: Resource type discriminator; always the literal `wallet_policy`.
        wallet_id:
          type: string
          description: The `ozav_wal_` id this policy belongs to.
        destinations:
          type: object
          required: [ mode, allowed ]
          properties:
            mode:
              type: string
              enum: [ allowlist, risk ]
              description: >
                `allowlist`: a send to an address outside `allowed` above
                `limits.auto_approve_below_usd` is parked for human review.
                `risk`: the risk screening decides on the fly and the list is
                optional.
            allowed:
              type: array
              description: Destinations that leave without human review, per network.
              items: { $ref: "#/components/schemas/WalletPolicyAllowedDestination" }
        limits:
          type: object
          required:
            [
              per_transaction_usd,
              daily_usd,
              auto_approve_below_usd,
              source,
              valid_until
            ]
          properties:
            per_transaction_usd:
              oneOf: [ { type: number }, { type: "null" } ]
              description: Ceiling for one send, in USD. `null` when `source` is
                `unconfigured`.
            daily_usd:
              oneOf: [ { type: number }, { type: "null" } ]
              description: Ceiling for the rolling 24-hour window, in USD.
            auto_approve_below_usd:
              oneOf: [ { type: number }, { type: "null" } ]
              description: >
                Below this amount a clean send to a destination NOT in `allowed`
                still leaves without review. `0` means every unlisted
                destination is reviewed.
            source:
              type: string
              enum: [ wallet, organization, unconfigured ]
              description: >
                `wallet`: a signed per-wallet record is in force.
                `organization`: no per-wallet record; your organization's
                default limits apply. `unconfigured`: neither exists - every
                send goes to human review.
            valid_until:
              oneOf: [ { type: string, format: date-time }, { type: "null" } ]
              description: When the per-wallet record expires; `null` unless `source` is
                `wallet`.
        approvals:
          type: object
          required: [ mode ]
          properties:
            mode:
              type: string
              enum: [ ozav_review, customer_quorum ]
              description: >
                `ozav_review`: only OZAV's compliance desk decides what falls
                outside the limits. `customer_quorum`: in addition,
                `required_approvals` of YOUR approver keys (scope
                `approvals:write`, other than the key that sent the request)
                must approve each covered send BEFORE OZAV signs it - the send
                answers `202 awaiting_approval` and executes on the last
                approval (`POST /approvals/{id}/approve`). Your quorum is one
                more gate, never one less: OZAV's screening, limits and review
                still apply after it.
            required_approvals:
              type: integer
              minimum: 1
              maximum: 10
              description: Only in `customer_quorum`. How many distinct approver keys must
                approve.
            above_usd:
              oneOf: [ { type: number }, { type: "null" } ]
              description: >
                Only in `customer_quorum`. `null`: every send needs the quorum.
                A number: only sends of that USD value or more (USDC and USDT
                count 1:1; a currency OZAV cannot value in USD always needs the
                quorum while one is set).
        pending_changes:
          type: array
          description: >
            Changes requested and not yet in force, newest first: awaiting
            review, approved (limits only - in force once the signed record is
            issued), or rejected.
          items: { $ref: "#/components/schemas/WalletPolicyPendingChange" }
        updated_at:
          oneOf: [ { type: string, format: date-time }, { type: "null" } ]
          description: When the policy in force last changed; `null` when nothing is
            configured yet.
    WalletPolicyAllowedDestination:
      type: object
      required: [ network, address, added_at ]
      properties:
        label:
          type: string
          description: The label you gave the destination ("cold vault", "treasury
            exchange"). Display only.
        network: { $ref: "#/components/schemas/Network" }
        address:
          type: string
          description: The destination address, as the custody plane matches it (EVM
            lower-cased).
        added_at:
          type: string
          format: date-time
    WalletPolicyPendingChange:
      type: object
      required: [ id, kind, status, requested_at, decided_at ]
      properties:
        id:
          type: string
          description: The change id, echoed in `wallet_policy.updated` when it is decided.
        kind:
          type: string
          enum: [ destination.add, limits.update, approvals.update ]
        status:
          type: string
          enum: [ pending_review, approved, rejected, awaiting_approval ]
          description: >
            `pending_review`: awaiting OZAV. `approved`: accepted, not yet in
            force (limits only - wait for the signed record; a destination is in
            `allowed` the moment it is approved, so it never shows as `approved`
            here). `rejected`: refused; `reason` may say why.
            `awaiting_approval` (`approvals.update` only): a loosening of your
            own quorum, waiting for the quorum in force to approve it at `POST
            /approvals/{approval_id}/approve`.
        requested_at: { type: string, format: date-time }
        decided_at:
          oneOf: [ { type: string, format: date-time }, { type: "null" } ]
        network: { $ref: "#/components/schemas/Network" }
        address: { type: string }
        label: { type: string }
        per_transaction_usd: { type: number }
        daily_usd: { type: number }
        auto_approve_below_usd:
          oneOf: [ { type: number }, { type: "null" } ]
        reason: { type: string }
        required_approvals:
          oneOf: [ { type: integer }, { type: "null" } ]
          description: Only in `approvals.update`; `null` means the quorum is being
            switched off.
        above_usd:
          oneOf: [ { type: number }, { type: "null" } ]
        approval_id:
          type: string
          description: Only in `approvals.update` - the `ozav_apr_` your approver keys
            decide.
    WalletPolicyChangeRequest:
      type: object
      additionalProperties: false
      description: Send at least one of `destinations`, `limits`, `approvals`, `delay`
        or `cosigner`.
      properties:
        destinations:
          type: object
          additionalProperties: false
          required: [ add ]
          properties:
            add:
              type: array
              minItems: 1
              maxItems: 20
              description: Destinations to add to the allowlist. Each is validated for its
                network before it is queued.
              items:
                type: object
                additionalProperties: false
                required: [ network, address ]
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  address:
                    type: string
                    description: The destination address. EVM addresses are stored lower-cased.
                  label:
                    type: string
                    minLength: 1
                    maxLength: 60
                    description: Optional label, kept with the destination once approved.
        limits:
          type: object
          additionalProperties: false
          description: >
            Fields you omit inherit the values in force. When nothing is in
            force yet, `per_transaction_usd` and `daily_usd` are both required.
          properties:
            per_transaction_usd:
              type: number
              minimum: 0
              description: Ceiling for one send, in USD.
            daily_usd:
              type: number
              minimum: 0
              description: Ceiling for the rolling 24-hour window, in USD. Must be at least
                `per_transaction_usd`.
            auto_approve_below_usd:
              oneOf: [ { type: number, minimum: 0 }, { type: "null" } ]
              description: >
                Below this a clean send to an unlisted destination leaves
                without review. Must not exceed `per_transaction_usd`. `null`
                means 0: every unlisted destination is reviewed.
        approvals:
          oneOf:
            - type: "null"
            - type: object
              additionalProperties: false
              required: [ required_approvals ]
              properties:
                required_approvals: { type: integer, minimum: 1, maximum: 10 }
                above_usd:
                  oneOf: [ { type: number, minimum: 0 }, { type: "null" } ]
          description: >
            Your own approval quorum for this wallet. `null` switches it off.
            Turning it on, or TIGHTENING it (more approvals, a lower or removed
            threshold), applies at once - no one has to approve more security.
            LOOSENING or switching it off while a quorum is in force creates an
            `approvals.update` change that the quorum in force must approve
            first (`409 APPROVAL_PENDING` while one waits). Works in the
            sandbox, unlike destinations and limits.
        cosigner:
          oneOf:
            - type: "null"
            - type: object
              additionalProperties: false
              required: [ url, secret, on_unavailable ]
              properties:
                url:
                  type: string
                  format: uri
                  description: >-
                    Your HTTPS endpoint. OZAV POSTs the pending transfer to it
                    and reads a decision. `http://` is rejected: it would put
                    the shared secret in the clear.
                secret:
                  type: string
                  minLength: 16
                  maxLength: 256
                  description: >-
                    Shared secret. OZAV signs each call `HMAC-SHA256(secret,
                    "{timestamp}.{body}")` and sends it as `X-Ozav-Signature`,
                    with the timestamp in `X-Ozav-Timestamp`. **Write-only**:
                    reading the policy never returns it.
                on_unavailable:
                  type: string
                  enum: [ hold, proceed ]
                  description: >-
                    What OZAV does when your endpoint does not answer. `hold`
                    parks the transfer for a person; `proceed` lets it continue
                    through OZAV's own controls, which all still apply. There is
                    no third option that approves on its own - an endpoint that
                    is down can never be more permissive than one that answers.
                timeout_ms:
                  type: integer
                  minimum: 1
                  maximum: 10000
                  default: 3000
          description: >
            Your own system in the signing path. `null` removes it.


            **An `allow` from your endpoint does not approve anything.** It
            means "I do not object", and the transfer goes on to the exact same
            OZAV controls it would face without a co-signer - sanctions, KYT,
            signed vault limit, velocity, allowlist, your approval quorum and
            your delay. What you gain is the power to REFUSE: a `deny` stops the
            transfer, always, with no exception and no setting.


            Reply `200` with `{"decision":"allow"}` or
            `{"decision":"deny","reason":"..."}`. Anything else - a non-2xx, an
            empty body, a proxy page - counts as "could not ask", and your
            `on_unavailable` decides.


            **Saving this does not switch it on.** OZAV's independent co-signer
            obeys a P-256-signed record issued on a separate machine, not this
            table - otherwise a compromised platform could point it at an
            endpoint that answers `allow` to everything. Until that record is
            issued, `GET /policy` reports `cosigner.status: pending_signature`
            and nothing is consulted.
        delay:
          oneOf:
            - type: "null"
            - type: object
              additionalProperties: false
              required: [ hours ]
              properties:
                hours: { type: integer, minimum: 1, maximum: 720 }
                above_usd:
                  oneOf: [ { type: number, minimum: 0 }, { type: "null" } ]
          description: >
            A mandatory delay before a covered send may leave. `null` switches
            it off. Every other control answers WHO may send, and a stolen
            credential has all of them; this one answers HOW LONG - it is the
            window in which a person reads the statement, does not recognise the
            withdrawal, and cancels.


            Turning it on, or TIGHTENING it (more hours, a lower or removed
            threshold), applies at once. LOOSENING or switching it off is QUEUED
            and only takes effect once the delay IN FORCE has elapsed - the
            clock defends itself with its own clock, so a stolen key cannot
            shorten the wait. `409 DELAY_CHANGE_PENDING` while one is queued;
            read `delay.loosens_at` for when it lands.


            The delay and the approval quorum COMPOSE: a send covered by both
            waits for the approvals AND the clock, never the sooner of the two.
    DepositAddress:
      type: object
      description: >
        An on-chain address that credits this wallet. Read `memo_required`
        before building a deposit instruction: on networks that attribute by
        memo, every customer shares one account and the memo is the only thing
        that routes funds to the right one. A deposit sent without it cannot be
        attributed.
      required: [ object, network, address, memo_required ]
      properties:
        object:
          type: string
          enum: [ deposit_address ]
          description: Resource type discriminator; always the literal `deposit_address`.
        network: { $ref: "#/components/schemas/Network" }
        address:
          type: string
          description: The on-chain address to send to.
        memo:
          type: [ string, "null" ]
          description: >
            Present and non-null exactly when `memo_required` is true. Send it
            with the deposit, unchanged. Null on address-per-user networks,
            where it must not be sent.
        memo_required:
          type: boolean
          description: >
            Whether the deposit is attributed by memo rather than by address. Do
            not infer this from `memo` being non-null - read this field.
        memo_type:
          type: [ string, "null" ]
          enum: [ text, null ]
          description: >
            HOW the memo must be sent. `text` means a Stellar MEMO_TEXT. Null
            where no memo applies.


            **Send it as a TEXT memo, never as a numeric one.** Most wallets and
            exchanges offer a numeric memo/tag field (Stellar MEMO_ID) by
            default, and our memo is a base64url string, so it will be rejected
            as invalid there - or, worse, accepted into the numeric field and
            sent as a different memo entirely. Attribution is an exact string
            match: a numeric memo never matches, and the deposit arrives
            unattributed.
        created_at:
          type: [ string, "null" ]
          format: date-time
          description: When the address was assigned to this wallet (RFC 3339, UTC).
            `null` on an address that predates the record being kept - it does
            not mean the address is new.
    UnsignedTransaction:
      type: object
      description: An unsigned EVM transaction for a self-custody wallet to sign and
        broadcast. OZAV never signs it.
      required: [ to, data, value, chain_id ]
      properties:
        to:
          {
            type: string,
            description: "The destination contract or account address (0x-prefixed hex)."
          }
        data:
          {
            type: string,
            description: "ABI-encoded calldata for the transaction (0x-prefixed hex; empty
              for a plain value transfer)."
          }
        value:
          {
            type: string,
            description: "Native-token amount to send, in wei, as a decimal string (\"0\"
              for token transfers)."
          }
        chain_id:
          {
            type: integer,
            description: "EIP-155 chain id identifying the target network."
          }
    TransactionIntent:
      type: object
      description: The result of building an unsigned transaction for a self-custody
        wallet. Ephemeral - not persisted and not re-fetchable.
      required: [ id, object, unsigned_transaction, expires_at ]
      properties:
        id:
          {
            type: string,
            description: "Opaque transaction-intent identifier, prefixed `ozav_txi_`."
          }
        object:
          {
            type: string,
            const: transaction_intent,
            description: "Resource type discriminator; always the literal
              `transaction_intent`."
          }
        wallet_id:
          {
            type: string,
            description: "The `ozav_wal_` id of the self-custody wallet the transaction is
              built for."
          }
        intent:
          type: string
          description: |
            The kind of transaction that was built:
            - `withdraw` - send funds out of the wallet to an external destination.
            - `transfer` - move funds from the wallet to another address.
            - `deposit` - inbound; not buildable here (a deposit is signed by the external payer).
          enum: [ deposit, withdraw, transfer ]
        unsigned_transaction: { $ref: "#/components/schemas/UnsignedTransaction" }
        expires_at:
          {
            type: string,
            format: date-time,
            description: "Validity horizon of the unsigned transaction, after which
              nonce/gas may be stale (RFC 3339 / ISO 8601, UTC)."
          }
    ScreeningSubject:
      description: |
        The screened subject. Provide exactly one of `customer_id`, `person`,
        `business`, or `wallet_address`. Inline subjects (`person` / `business`)
        enable standalone screening with no customer record.
      oneOf:
        - type: object
          description: Screen an existing customer by id.
          required: [ customer_id ]
          properties:
            customer_id:
              {
                type: string,
                description: "The `ozav_cus_` id of an existing customer to screen."
              }
        - type: object
          description: Screen a person inline (no customer record required).
          required: [ person ]
          properties:
            person:
              type: object
              description: The natural person to screen. Screened as given - no customer
                record is created and nothing is stored against a customer.
              required: [ name ]
              properties:
                name: { type: string, description: "Full name of the person to screen." }
                date_of_birth:
                  {
                    type: string,
                    format: date,
                    description: "Date of birth (ISO 8601 date) - helps disambiguate matches."
                  }
                country:
                  {
                    type: string,
                    description: "Country of the person as an ISO 3166-1 alpha-2 code.",
                    pattern: "^[A-Z]{2}$"
                  }
        - type: object
          description: Screen a business inline (no customer record required).
          required: [ business ]
          properties:
            business:
              type: object
              description: The legal entity to screen. Screened as given - no customer record
                is created and nothing is stored against a customer.
              required: [ legal_name ]
              properties:
                legal_name:
                  {
                    type: string,
                    description: "Registered legal name of the business to screen."
                  }
                country:
                  {
                    type: string,
                    description: "Country of registration as an ISO 3166-1 alpha-2 code.",
                    pattern: "^[A-Z]{2}$"
                  }
        - type: object
          required: [ wallet_address ]
          description: >
            Wallet-address screening is not yet available: this variant
            currently returns 422 FEATURE_NOT_AVAILABLE. Contact your OZAV
            account manager for early access.
          properties:
            wallet_address:
              type: object
              description: The blockchain address to screen. NOT AVAILABLE YET - sending this
                variant returns 422 `FEATURE_NOT_AVAILABLE`, it does not screen
                and return `clear`.
              required: [ address, network ]
              properties:
                address: { type: string, description: "The blockchain address to screen." }
                network: { $ref: "#/components/schemas/Network" }
    ScreeningMatch:
      type: object
      description: A single match against a designated list.
      required: [ list_source, matched_name, strength ]
      properties:
        list_source:
          type: string
          description: |
            Which designated-list source produced the match:
            - `un` - United Nations Security Council consolidated list.
            - `ofac` - US OFAC (SDN and related lists).
            - `eu` - European Union consolidated sanctions list.
            - `uk` - UK OFSI consolidated list.
            - `national` - a national designation list.
          enum: [ un, ofac, eu, uk, national ]
        matched_name:
          {
            type: string,
            description: "The name on the list that matched the screened subject."
          }
        strength:
          type: string
          description: |
            How strong the name match is:
            - `exact` - the names match exactly.
            - `alias` - the subject matched a known alias of a listed entity.
            - `fuzzy` - a weaker, token-level match (surfaced for review, never auto-cleared).
          enum: [ exact, alias, fuzzy ]
        list_entry_ref:
          {
            type: string,
            description: "The list's own reference id for the matched entry (e.g. an OFAC
              SDN number), for audit."
          }
    EvidencePack:
      type: object
      description: |
        What OZAV verified and decided about one customer, assembled at request
        time. An empty section means absence of record, never approval.
      properties:
        clienteRef:
          {
            type: string,
            description: "The customer id this record is about (`ozav_cus_...`)."
          }
        geradoEm:
          {
            type: string,
            format: date-time,
            description: "When this record was assembled."
          }
        veredito:
          type: string
          enum: [ aprovado, em_analise, bloqueado, sem_registro ]
          description: "`sem_registro` means the customer has not been verified - it is
            not an approval."
        fundamento: { type: string, description: "Why this verdict, in one sentence." }
        secoesIndisponiveis:
          type: array
          description: "Sections that could not be read. Present means the record does not
            assert them."
          items: { type: string }
        resumo:
          type: object
          properties:
            verificacoes: { type: integer }
            triagens: { type: integer }
            documentos: { type: integer }
            decisoes: { type: integer }
            alertas_abertos: { type: integer }
            congelamentos_ativos: { type: integer }
            decisoes_com_desfecho:
              {
                type: integer,
                description: "How many decisions already carry what happened afterwards."
              }
        verificacoes:
          type: array
          items:
            type: object
            properties:
              capacidade: { type: string }
              resultado: { type: string }
              concluida_em: { type: [ string, "null" ], format: date-time }
        triagens:
          type: array
          items:
            type: object
            properties:
              tipo: { type: string }
              houve_correspondencia: { type: boolean }
              nivel_de_risco: { type: [ string, "null" ] }
              triada_em: { type: [ string, "null" ], format: date-time }
              decidida_em: { type: [ string, "null" ], format: date-time }
        documentos:
          type: array
          items:
            type: object
            properties:
              categoria: { type: [ string, "null" ] }
              veredito: { type: string }
              avaliador: { type: string }
              avaliado_em: { type: [ string, "null" ], format: date-time }
              parser:
                {
                  type: [ string, "null" ],
                  description: "Which assessor version produced the verdict."
                }
        decisoes:
          type: array
          items:
            type: object
            properties:
              fluxo: { type: string }
              efeito: { type: string }
              barrou: { type: boolean }
              movimento:
                {
                  type: [ string, "null" ],
                  description: "The movement this decision governed."
                }
              decidida_em: { type: string, format: date-time }
              desfecho:
                {
                  type: [ string, "null" ],
                  description: "What happened afterwards. `null` means not yet known."
                }
        alertas:
          type: array
          items:
            type: object
            properties:
              tipo: { type: string }
              severidade: { type: [ string, "null" ] }
              estado: { type: string }
              aberto_em: { type: [ string, "null" ], format: date-time }
        congelamentos:
          type: array
          items:
            type: object
            properties:
              estado: { type: string }
              escopo: { type: [ string, "null" ] }
              congelado_em: { type: [ string, "null" ], format: date-time }
              liberado_em: { type: [ string, "null" ], format: date-time }
        atestacaoDeReliance:
          type: [ object, "null" ]
          description: "Present when the integrator performed and attested the identity
            verification itself."
          properties:
            metodo: { type: string }
            emitida_em: { type: [ string, "null" ], format: date-time }
            expira_em: { type: [ string, "null" ], format: date-time }
    Screening:
      type: object
      description: A point-in-time designated-list screening of a subject.
      required: [ id, object, subject, status, result ]
      properties:
        id:
          {
            type: string,
            description: "Opaque screening identifier, prefixed `ozav_scr_`."
          }
        object:
          {
            type: string,
            const: screening,
            description: "Resource type discriminator; always the literal `screening`."
          }
        subject: { $ref: "#/components/schemas/ScreeningSubject" }
        lists:
          type: array
          description: |
            The designated lists that were requested / screened against. Each item is one of:
            - `sanctions` - consolidated sanctions / designated-persons lists.
            - `pep` - politically exposed persons.
            - `adverse_media` - negative-news screening.
          items: { type: string, enum: [ sanctions, pep, adverse_media ] }
        status:
          type: string
          description: |
            Processing state of the screening:
            - `pending` - the screening is still running.
            - `completed` - the screening finished; read the outcome from `result`.
          enum: [ pending, completed ]
        result:
          type: string
          description: |
            Conservative, fail-closed outcome:
            - `clear` - every requested list was fully consulted with zero matches.
            - `match_found` - a strong (exact/alias) designated-list match.
            - `review` - a weaker match, or a requested list that could not be fully
              consulted; never silently cleared.

            TELLING THE TWO `review` CAUSES APART, because they call for OPPOSITE actions and the
            answer is already in this response - read `matches`:

            - `review` with a NON-EMPTY `matches` - a weak (token-level) hit. A human decides.
              Retrying changes nothing; the same subject screens the same way.
            - `review` with an EMPTY `matches` - every list you requested came back without a hit,
              and at least one of them could not be fully consulted. Nobody matched anything. This
              is transient on our side: RETRY LATER rather than queueing the subject for review.

            The two are exhaustive for `review`, by construction: a weak hit is counted from the
            same array that `matches` is serialized from, so an empty `matches` leaves an
            unconsultable source as the only remaining cause.

            WHICH source degraded is not published. It is an operational fact about OZAV's
            ingestion, not about your subject, and the action it implies (retry) is the same
            whichever one it was.
          enum: [ clear, match_found, review ]
        matches:
          type: array
          description: |
            The matches found. Empty when the outcome is `clear` - and ALSO empty on the
            `review` that means a list could not be consulted, which is why `result` and this
            field have to be read together. See `ScreeningMatch`.
          items: { $ref: "#/components/schemas/ScreeningMatch" }
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the screening was created (RFC 3339 / ISO 8601, UTC)."
          }
    Verification:
      type: object
      description: |
        An identity / business verification session (KYC / KYB) for a customer.
        Completing or simulating a verification updates the customer's KYC
        status. In sandbox, approval can materialize simulated wallet and
        BRL/Pix account projections. Live wallet provisioning and live bank
        account opening remain separate enabled steps.
      required: [ id, object, customer_id, status ]
      properties:
        id:
          {
            type: string,
            description: "Opaque verification identifier, prefixed `ozav_vrf_`."
          }
        object:
          {
            type: string,
            const: verification,
            description: "Resource type discriminator; always the literal `verification`."
          }
        customer_id:
          {
            type: string,
            description: "The `ozav_cus_` id of the customer being verified."
          }
        session_url:
          type: string
          format: uri
          description: |
            Hosted verification session URL - redirect the end-user here to complete identity
            capture (government ID, selfie, liveness; KYB/UBO for a business customer).

            **In sandbox this field never appears, by design.** A `test` key never starts a hosted
            session, because a real session bills a third party per attempt. Exercise the outcome
            with `POST /verifications/{verification_id}/simulate` instead - that is the sandbox
            affordance, and it lets you drive `approved` or `rejected` on purpose.

            **With a `live` key, two things must both be true**, and the second is not something you
            control:

            1. Your `capabilities` include at least one that hosted capture covers:
               `kyc_document`, `liveness`, `face_match`, `age_estimation`, `kyb`, `ubo`. Asking only
               for list/data checks (`aml_name_screening`, `email_verify`, `economic_capacity`, ...)
               never starts a session, because there is nothing for the end-user to do.
            2. Hosted capture is switched on at OZAV. This is a single platform-wide setting,
               **not** something enabled per organization - there is nothing to request for your
               account. While it is off, no `live` key starts a session.

            When either condition is missing - or the key is a `test` key - the verification is still
            created and is a valid record: it simply stays `pending` with `checks` at `not_started`
            and **no `session_url`**. That is not an error and retrying will not produce one.

            The field is never fabricated: if the session cannot be created, the request fails with
            `503 VERIFICATION_SESSION_UNAVAILABLE` and nothing is persisted, rather than returning a
            record with a broken link.
        status:
          type: string
          description: |
            Overall state of the verification session:
            - `pending` - created; checks have not started.
            - `in_progress` - one or more checks are running.
            - `completed` - every check reached an outcome.
            - `expired` - the session expired before completing.
          enum: [ pending, in_progress, completed, expired ]
        checks:
          type: object
          description: Per-capability check results, keyed by `CapabilityKind`.
          additionalProperties: { $ref: "#/components/schemas/CheckResult" }
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the verification session was created (RFC 3339 / ISO 8601,
              UTC)."
          }
    ApiKey:
      type: object
      description: An API key's metadata - never its secret, which is shown only at
        creation and rotation.
      required: [ id, object, key_prefix, environment, scopes ]
      properties:
        id:
          {
            type: string,
            description: "Opaque API-key identifier, prefixed `ozav_key_`."
          }
        object:
          {
            type: string,
            const: api_key,
            description: "Resource type discriminator; always the literal `api_key`."
          }
        name: { type: string, description: "Human-readable label you gave the key." }
        key_prefix:
          {
            type: string,
            description: "Non-secret display prefix of the key (e.g. `ozav_sk_live_a1b2`) -
              enough to identify it without revealing the secret."
          }
        environment:
          type: string
          description: |
            Which environment the key operates against:
            - `test` - the sandbox; moves no real money.
            - `live` - production; can move real money.
          enum: [ test, live ]
        scopes:
          type: array
          description: The scopes granted to this key (intersected with the organization's
            entitlements at request time).
          items: { type: string }
        role:
          type: string
          enum: [ admin, operator, viewer ]
          description: |
            The role this key was minted with, when it was minted with one. It is
            a label and a mint-time template - **not** part of the permission
            decision, which stays `entitlements ∩ scopes`. A rotated key keeps it.

            **Absent** on a key whose scopes were listed one by one, which is
            every key created before roles existed. The field is omitted rather
            than sent as `null`, because `null` here would read as "this key has
            no powers" instead of "this key was not minted from a template".
        last_used_at:
          {
            type: string,
            format: date-time,
            description: "When the key was last used to authenticate a request (RFC 3339 /
              ISO 8601, UTC)."
          }
        expires_at:
          {
            type: string,
            format: date-time,
            description: "When the key expires, if it has an expiry (e.g. an old secret's
              cutover time after a rotation)."
          }
        created_at:
          {
            type: string,
            format: date-time,
            description: "When the key was created (RFC 3339 / ISO 8601, UTC)."
          }
    ApiKeyWithSecret:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          properties:
            secret:
              type: string
              description: The full secret key; returned only on creation and rotation.
    WalletTransferBatch:
      type: object
      description: |
        Up to 50 sends from one managed wallet, on one network, in one currency,
        decided once and run one at a time in order.

        `legs` always has one entry per leg you sent, in the order you sent
        them - including the ones that were never attempted, so the count always
        matches your request. A leg with `id: null` does not exist anywhere:
        nothing was sent for it.
      required:
        [
          id,
          object,
          wallet_id,
          status,
          currency,
          total_amount,
          leg_count,
          legs
        ]
      properties:
        id: { type: string, description: "The opaque batch id (`ozav_wtb_...`)." }
        object: { type: string, enum: [ wallet_transfer_batch ] }
        wallet_id: { type: string, description: "The sending wallet (`ozav_wal_...`)." }
        status:
          type: string
          enum:
            [
              executing,
              completed,
              partially_completed,
              failed,
              awaiting_approval,
              rejected,
              expired
            ]
          description: >-
            `completed` - every leg settled. `partially_completed` - some legs
            settled, or may have settled, and some did not; what settled moved
            real money and is not reversible. `failed` - no leg settled and none
            is in doubt. `executing` - the run has not been closed out yet; read
            `legs[]` and reconcile rather than re-sending. `awaiting_approval`,
            `rejected`, `expired` - the batch is (or was) held by your approval
            quorum and nothing has been signed.
        currency: { $ref: "#/components/schemas/Currency" }
        total_amount:
          allOf: [ { $ref: "#/components/schemas/DecimalAmount" } ]
          description: >-
            The sum of the legs. **This is the figure your approval quorum's
            `above_usd` threshold is applied to** - not any single leg - so ten
            legs of 9,000 cross a 10,000 threshold.
        leg_count: { type: integer, minimum: 1, maximum: 50 }
        legs:
          type: array
          items:
            type: object
            required: [ index, id, status, amount, destination, transaction_hash ]
            properties:
              index:
                type: integer
                description: Position in the batch, from 0, in the order the legs were
                  attempted.
              id:
                type: [ string, "null" ]
                description: >-
                  The `wallet_transfer` this leg became (`ozav_wtr_...`), or
                  `null` when the leg never became a record at all - not
                  attempted, or refused before the send was even claimed. A leg
                  that was attempted and refused still has an id, and reading
                  the batch back returns the same one.
              status:
                type: string
                enum: [ pending, executing, completed, needs_review, failed, cancelled ]
                description: >-
                  `completed` - settled on chain. `failed` - attempted and
                  refused **before anything was signed**; read `failure`.
                  **`needs_review` - THE SEND MAY HAVE GONE OUT** and the record
                  did not close; it is under reconciliation, and re-sending it
                  can pay twice. `executing` - still in flight. **`cancelled` -
                  NOT ATTEMPTED**, because an earlier leg stopped the run; no
                  money was sent and no transaction exists. `pending` - the
                  batch is waiting on your approval quorum and nothing has run
                  yet.
              amount: { $ref: "#/components/schemas/DecimalAmount" }
              destination: { type: string }
              transaction_hash: { type: [ string, "null" ] }
              failure:
                type: object
                description: Present only on a `failed` leg.
                properties:
                  code: { type: string }
                  message: { type: string }
        approval:
          type: object
          description: >-
            Present while the batch is held by your approval quorum, and on a
            batch that was held. One approval covers the whole batch.
          properties:
            id: { type: string, description: "The approval id (`ozav_apr_...`)." }
            required_approvals: { type: integer }
            expires_at: { type: string, format: date-time }
    WebhookEventType:
      type: string
      description: |
        Event types currently emitted by API v2 webhooks. The catalog is intentionally
        limited to events that actually fire today:
        - `webhook.test` - queued by `POST /webhook-endpoints/{id}/test`.
        - `customer.created` / `customer.updated` - emitted by the v2 customers surface.
        - `verification.created` / `verification.updated` - emitted by the v2 verification surface.
        - `screening.completed` - emitted when a screening completes.
        - `transfer.updated` / `transfer.completed` / `transfer.failed` - emitted by the v2 transfer surface.
        - `card.created` / `card.updated` - **card LIFECYCLE only.** `card.created` fires on
          `POST /cards`; `card.updated` on a spending-controls change (`PATCH /cards/{id}`) and on
          freeze / unfreeze / close.


          **There is deliberately no per-transaction card event.** Nothing fires when a card is
          used: no purchase, no authorisation, no decline, no settlement. Card spend is
          **pull-only** - reconcile with `GET /cards/{card_id}/transactions` (settled movement) and
          `GET /cards/{card_id}/authorizations` (holds that may still reverse). That is a real
          limit, not an omission from this list: the authorisation pipeline has no client-webhook
          emitter, and publishing an event with no emitter would advertise something that never
          fires.
        - `account.updated` - emitted when a named account's public status changes, so an
          integrator learns their customer can receive money without polling GET /accounts.
        - `deposit.credited` - emitted when crypto sent to one of your wallets has been credited
          and is spendable. Receiving crypto needs no call from you: the arrival is observed on
          chain and credited on its own, and it already shows up in `GET /transactions` and in
          `GET /wallets/{wallet_id}/balances`. This event is the push, so you do not have to poll
          for it. There is deliberately no `deposit.pending`: before a deposit is credited it can
          still reorganize on chain, and telling you early would invite you to act on value that
          may vanish.
        - `requirement.due` - emitted when compliance review determines WHICH documents are still
          missing for a customer, so you learn what to collect without polling. There is no
          `requirement.satisfied`: the customer's own `GET /customers/{id}` shrinks
          `requirements.documents_due` when a file lands, and announcing a second event would need
          a second emitter to avoid becoming an empty promise.
        - `document.reviewed` - emitted when OZAV's own document review ACCEPTS or REJECTS a
          supporting document (proof of address, bank statement, proof of income, company
          document): `{customer_id, document_id, key, label, status: accepted|rejected, reason,
          reason_label, reviewed_at}`. A rejection is followed by a fresh `requirement.due` for the
          same document, with a new deadline and the `reason`. Not emitted while a document is
          waiting on a human reviewer.
        - `wallet_policy.updated` - emitted when a change to a managed wallet's policy is
          DECIDED: `{wallet_id, change: {id, kind: destination.add|limits.update, status, ...}}`.
          For a destination, `status: applied` means it is already in `destinations.allowed`.
          For limits, `status: approved` means accepted and NOT yet in force - the signed record
          is issued minutes later; confirm with `GET /wallets/{wallet_id}/policy`
          (`limits.valid_until`). `rejected` carries a `reason` when one was given. Not emitted
          when you PATCH: proposing is not changing. Also emitted for `kind: approvals.update`
          when your own approval quorum is applied or a loosening of it is decided. And for `kind: destination.remove` when you
          take a destination out of the address book (`DELETE /wallets/{id}/addresses/...`).
        - `transfer.approval_required` - emitted when a custody send stops BEFORE execution because
          the wallet's policy (`approvals.mode: customer_quorum`) requires your approver keys to
          approve it: `{transfer, approval_id, required_approvals, expires_at}` from
          `POST /wallets/{id}/transfers`, or `{transfer}` with `status: awaiting_approval` and
          `approval_id` from `POST /transfers`. Nothing has been signed.
        - `transfer.approved` - emitted when the last required approval arrives and the send is
          executed in that same request: `{approval_id, transfer}` where `transfer.status` is the
          OUTCOME (`completed`/`converting` on success, `failed` with `failure` on a clean refusal).
          A `POST /transfers` send additionally emits its usual `transfer.updated`.
          **When the approval held a BATCH, the payload carries `batch` instead of `transfer`**, with
          every leg and its own status - branch on which key is present.
        - `transfer.rejected` - emitted when any approver key rejects: `{approval_id, transfer_id,
          wallet_id, reason, rejected_by_api_key_id}` - or **`batch_id` in place of `transfer_id`
          when the approval held a batch.** The held send is released; nothing moves.
          There is no expiry event: an approval unanswered for 72 h reads `expired` on
          `GET /approvals/{id}`, and announcing an event that only fires when someone reads would
          promise a push that does not come on its own.
        - `transfer_batch.completed` / `transfer_batch.failed` - emitted once when a batch
          (`POST /wallets/{id}/transfer-batches`) finishes running: `{batch}`, carrying every leg
          with its own status and transaction hash. `transfer_batch.completed` covers
          `partially_completed` too, because some money did move; `transfer_batch.failed` means no
          leg settled. There is deliberately no event per leg - the batch exists to be ONE
          decision, and N events per batch would hand back the loop the route took away. There is
          no `transfer_batch.created` either: the create is already the 201 response. A batch held
          by your quorum emits `transfer.approval_required` like a single send, with `batch` in
          place of `transfer`.
        - `wallet.frozen` / `wallet.unfrozen` - emitted when YOU freeze a managed wallet
          (`POST /wallets/{id}/freeze`) and when your freeze is lifted (`POST /wallets/{id}/unfreeze`,
          or the approval of a `wallet_unfreeze` when your quorum is on): `{wallet_id, reason, since,
          frozen_by_api_key_id}` / `{wallet_id, unfrozen_by_api_key_id, approval_id?}`. While frozen,
          every send from the wallet is refused with `409 WALLET_FROZEN` before anything is signed.
          Not emitted for OZAV's own reserve freezes.
      enum:
        [
          webhook.test,
          customer.created,
          customer.updated,
          verification.created,
          verification.updated,
          screening.completed,
          transfer.updated,
          transfer.completed,
          transfer.failed,
          card.created,
          card.updated,
          account.updated,
          deposit.credited,
          requirement.due,
          document.reviewed,
          wallet_policy.updated,
          transfer.approval_required,
          transfer.approved,
          transfer.rejected,
          wallet.frozen,
          wallet.unfrozen,
          transfer_batch.completed,
          transfer_batch.failed
        ]
    WebhookEndpointCreateRequest:
      type: object
      required: [ url, enabled_events ]
      properties:
        url:
          type: string
          format: uri
          pattern: "^https://"
          description: HTTPS URL that will receive signed POST deliveries.
        enabled_events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }
          description: Event types to deliver to this endpoint.
        description:
          type: string
          maxLength: 255
          description: Optional human-readable label for the endpoint.
    WebhookEndpointUpdateRequest:
      type: object
      minProperties: 1
      properties:
        url:
          type: string
          format: uri
          pattern: "^https://"
          description: Replacement HTTPS delivery URL.
        enabled_events:
          type: array
          minItems: 1
          description: REPLACES the subscription list; it is not merged with the current
            one. Send the full set you want, including the types you already
            had, or you will silently unsubscribe from them.
          items: { $ref: "#/components/schemas/WebhookEventType" }
        status:
          type: string
          description: Set to `disabled` to stop deliveries without deleting the endpoint
            (the signing secret and the subscription list survive), or back to
            `active` to resume.
          enum: [ active, disabled ]
        description:
          type: string
          maxLength: 255
          description: Replacement human label. Not used for routing.
    WebhookEndpoint:
      type: object
      description: Webhook endpoint configuration. Secrets, secret hashes, Vault refs,
        and internal ids are never returned.
      required: [ id, object, url, enabled_events, status, created_at, updated_at ]
      properties:
        id:
          {
            type: string,
            description: "Opaque webhook endpoint identifier, prefixed `ozav_wh_`."
          }
        object:
          {
            type: string,
            const: webhook_endpoint,
            description: "Resource type discriminator; always the literal
              `webhook_endpoint`."
          }
        url:
          type: string
          format: uri
          description: The HTTPS URL that receives the deliveries. Verify the signature on
            every request to it (see the Webhooks section); an unverified
            receiver accepts forged events.
        enabled_events:
          type: array
          description: The event types this endpoint is subscribed to. An event whose type
            is not listed here is never delivered to this endpoint, and no
            record of a skipped delivery is created.
          items: { $ref: "#/components/schemas/WebhookEventType" }
        status:
          type: string
          description: |
            `active` - deliveries are attempted. `disabled` - nothing is delivered and nothing is
            queued for later; events that occur while disabled are NOT replayed on re-enable.

            OZAV does not disable an endpoint on its own. A URL that has failed every delivery for
            weeks still reads `active`, so `status` alone is not a health signal - read `health`.
          enum: [ active, disabled ]
        description:
          type: [ string, "null" ]
          description: Free-form label you set. Never used for routing or delivery.
        health:
          type: object
          description: |
            Read-only delivery health. `last_success_at: null` on an endpoint that has existed for
            a while means it has NEVER delivered, which is the failure that otherwise stays
            invisible for weeks. Absent when the aggregate could not be read; its absence never
            fails the request.

            `last_error` separates the two OPPOSITE causes, because the fix differs: an `HTTP 401`
            means your server answered and REFUSED (a signature problem), while a transport error
            such as `dns error: ...` means nothing answered at all (hostname, TLS, firewall) and no
            signature change will help.
          properties:
            last_success_at:
              type: [ string, "null" ]
              format: date-time
              description: When a delivery to this endpoint last got a 2xx. `null` means
                never.
            last_failure_at:
              type: [ string, "null" ]
              format: date-time
              description: When a delivery to this endpoint last failed (non-2xx or transport
                error). `null` means no failure has been recorded.
            last_error:
              type: [ string, "null" ]
              description: The most recent failure, verbatim - either the HTTP status your
                server returned or the transport-level error. `null` when
                nothing has failed.
        created_at:
          type: string
          format: date-time
          description: When the endpoint was registered (RFC 3339, UTC).
        updated_at:
          type: string
          format: date-time
          description: When the endpoint was last modified (RFC 3339, UTC).
    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          properties:
            secret:
              type: string
              description: Signing secret (`ozav_whsec_...`); returned only on creation and
                rotation.
    WebhookDeliveryStatus:
      type: string
      enum: [ pending, delivering, succeeded, failed, exhausted ]
    WebhookDelivery:
      type: object
      description: |
        One delivery attempt stream for one endpoint/event pair. OZAV retries non-2xx
        and network failures with backoff until the delivery succeeds or exhausts its
        attempt budget. Response bodies are stored only as truncated previews.
      required:
        [
          id,
          object,
          endpoint_id,
          event_id,
          event_type,
          status,
          attempt_count,
          next_attempt_at,
          created_at,
          updated_at
        ]
      properties:
        id:
          {
            type: string,
            description: "Opaque delivery identifier, prefixed `ozav_wd_`."
          }
        object:
          {
            type: string,
            const: webhook_delivery,
            description: "Resource type discriminator; always the literal
              `webhook_delivery`."
          }
        endpoint_id: { type: string, description: "The `ozav_wh_` endpoint this delivery targets." }
        event_id: { type: string, description: "The `ozav_evt_` event being delivered." }
        event_type: { $ref: "#/components/schemas/WebhookEventType" }
        status: { $ref: "#/components/schemas/WebhookDeliveryStatus" }
        attempt_count:
          {
            type: integer,
            minimum: 0,
            description: "Number of delivery attempts made so far."
          }
        next_attempt_at:
          type: string
          format: date-time
          description: When the next retry is scheduled (RFC 3339, UTC). It keeps moving
            while the backoff runs; once `status` is `succeeded` or `exhausted`
            it stops being meaningful.
        last_attempt_at:
          type: [ string, "null" ]
          format: date-time
          description: When the most recent attempt was made. `null` before the first
            attempt.
        delivered_at:
          type: [ string, "null" ]
          format: date-time
          description: When an attempt got a 2xx. `null` on a delivery that has never
            succeeded - which, on an endpoint whose `response_status` is also
            `null`, means nothing at the URL ever answered.
        response_status:
          type: [ integer, "null" ]
          description: Last HTTP status code returned by your endpoint, if a response was
            received.
        response_preview:
          type: [ string, "null" ]
          description: Truncated preview of the last response body.
        error:
          type: [ string, "null" ]
          description: Last network/timeout error, if no HTTP response was received.
        created_at:
          type: string
          format: date-time
          description: When the delivery row was created (RFC 3339, UTC) - effectively
            when the event was queued for this endpoint.
        updated_at:
          type: string
          format: date-time
          description: When the delivery row last changed (RFC 3339, UTC) - every attempt
            moves it. }
    WebhookEvent:
      type: object
      required: [ id, object, type, created_at, data ]
      description: |
        Canonical event envelope POSTed to webhook endpoints. OZAV signs the exact
        JSON body bytes with HMAC-SHA256. Deliveries include:
        - `X-OZAV-Signature: t=<unix>,v1=<hex>`, where `v1` is HMAC-SHA256 over
          `"<t>.<raw body>"`, keyed with your endpoint secret **exactly as returned,
          including the `ozav_whsec_` prefix**. The prefix is part of the key, not a
          display label: stripping it produces a completely different digest and every
          delivery fails verification. `v1` is lowercase hex; parse it out of the header
          rather than comparing the whole `t=...,v1=...` value.
        - `X-OZAV-Event-ID`, stable across retries for idempotency.
        - `X-OZAV-Delivery-ID`, unique to this endpoint/event delivery row.

        Reject signatures whose timestamp is outside your tolerance window (five
        minutes is recommended), and dedupe by `X-OZAV-Event-ID`.
      properties:
        id: { type: string, description: "Opaque event identifier, prefixed `ozav_evt_`." }
        object:
          {
            type: string,
            const: event,
            description: "Resource type discriminator; always the literal `event`."
          }
        type: { $ref: "#/components/schemas/WebhookEventType" }
        created_at:
          type: string
          format: date-time
          description: When the event occurred (RFC 3339, UTC), not when this delivery
            attempt was made. It stays the same across retries - use it, not
            receipt time, to order events.
        data:
          type: object
          additionalProperties: true
          description: |
            The affected resource, in the SAME shape its `GET` returns. An event about a transfer
            carries the `Transfer` object; one about a customer carries the `Customer`.

            It is a SNAPSHOT at event time, and it can be stale by the time you read it. When the
            current state matters (a decision about money), re-fetch by id rather than acting on
            this body. Deliveries can also arrive out of order.
    Page:
      type: object
      description: The uniform envelope every list endpoint returns.
      required: [ data, has_more ]
      properties:
        data:
          type: array
          description: The page of results. Each list endpoint documents its own item type.
          items: {}
        has_more:
          {
            type: boolean,
            description: "True when more results exist beyond this page - keep paging with
              `starting_after`."
          }
        next_cursor:
          type: [ string, "null" ]
          description: Opaque cursor for the next page; pass it as `starting_after`. Null
            on the last page.
    ErrorDetail:
      type: object
      description: A single field-level error, when the top-level error decomposes
        into several.
      properties:
        code:
          {
            type: string,
            description: "Machine-readable code for this specific field error."
          }
        message:
          {
            type: string,
            description: "Human-readable explanation of this field error."
          }
        param: { type: string, description: "The request field this error refers to." }
    ErrorEnvelope:
      type: object
      description: The envelope every error response uses, alongside the HTTP status code.
      required: [ error ]
      properties:
        error:
          type: object
          description: The error payload; always present on a non-2xx response.
          required: [ type, code, message, request_id ]
          properties:
            type:
              type: string
              description: |
                Stable high-level error class (see the "Errors" section for the full table):
                - `invalid_request_error` - malformed or unfulfillable input.
                - `authentication_error` - missing, invalid, revoked, or expired key.
                - `permission_error` - authenticated but not permitted (entitlement or scope).
                - `not_found_error` - the resource does not exist or is not visible.
                - `conflict_error` - conflicts with existing state (e.g. idempotency-key reuse).
                - `rate_limit_error` - too many requests; honor `Retry-After`.
                - `idempotency_error` - the idempotency layer was unreachable; retry.
                - `api_error` - an unexpected server-side or dependency error.
              enum:
                [
                  invalid_request_error,
                  authentication_error,
                  permission_error,
                  not_found_error,
                  conflict_error,
                  rate_limit_error,
                  idempotency_error,
                  api_error
                ]
            code:
              type: string
              description: |
                Stable machine-readable code. Per-operation codes include ENTITLEMENT_REQUIRED,
                INSUFFICIENT_SCOPE, IDEMPOTENCY_KEY_REUSED. Platform-wide codes any operation may
                return: API_V2_DISABLED (503, kill switch off), AUTH_UNAVAILABLE and
                LOOKUP_UNAVAILABLE (503, fail-closed on a dependency error), and, where an
                operation reaches a provisioning path outside your entitlements,
                PROVISIONING_NOT_IMPLEMENTED and
                VERIFICATION_SESSION_NOT_IMPLEMENTED (501).
            message: { type: string, description: "Human-readable description of what went wrong." }
            param:
              {
                type: string,
                description: "The request field the error refers to, when applicable."
              }
            request_id:
              {
                type: string,
                description: "Identifier of this exact request - include it in every support
                  request."
              }
            details:
              description: |
                Usually an array of error detail objects (`{code, message,
                param}`). The router's 404 catch-all instead returns a
                diagnostic object variant:
                `{"diag": {"method": "...", "pathParts": ["..."]}}`.
              oneOf:
                - type: array
                  items: { $ref: "#/components/schemas/ErrorDetail" }
                - type: object
                  properties:
                    diag:
                      type: object
                      description: Router 404 diagnostic - the parsed method and path segments of the
                        unmatched request.
                      properties:
                        method: { type: string, description: "The HTTP method of the unmatched request." }
                        pathParts:
                          type: array
                          description: The path segments the router parsed from the unmatched request.
                          items: { type: string }
