openapi: 3.1.0
info:
  title: Venly Finance API
  description: |
    REST API for the Venly Finance platform:
    - Party management (Individuals & Organisations)
    - Identity verification — hosted KYC/KYB links and Sumsub share-token forwarding
    - Account management with party association
    - Wallet balances & token allowances on supported chains
    - Virtual bank accounts for global pay-in (EUR SEPA, USD ACH/WIRE/RTP/SWIFT)
    - Fiat-to-crypto payment sessions (pay-in)
    - Bank pay-outs — beneficiary allow-listing, routes, and PULL/PUSH pay-outs
    - Account-to-account fiat & crypto transfers
    - EIP-2612 permits and allowances
    - Webhook registration for asynchronous event delivery

    ## Authentication

    All endpoints use OAuth2 client credentials. Obtain a token first, then include it in every request:

    **Step 1 — Get a token:**
    ```bash
    curl -X POST https://login.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d "grant_type=client_credentials&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}"
    ```

    **Step 2 — Use the token:**
    ```
    Authorization: Bearer {access_token}
    ```

    Tokens expire after **5 minutes**. Implement refresh logic in your client.

  version: 1.5.0
  contact:
    name: Venly Support
    email: support@venly.io
    url: https://docs.venlyfinance.com
  license:
    name: Proprietary
  x-logo:
    url: https://venlyfinance.com/logo.png
  x-security-contact: security@venly.io

servers:
  - url: https://api.venlyfinance.com/v1
    description: Production
  - url: https://api-staging.venlyfinance.com/v1
    description: Staging

security:
  - OAuth2: []

tags:
  - name: Parties
    description: Party management (Individuals & Organisations)
  - name: Accounts
    description: Account management, party-role associations
  - name: Wallets
    description: Blockchain wallet balances
  - name: Virtual Bank Accounts
    description: Virtual bank account payment references for global payments
  - name: Fiat-to-crypto Payment Sessions
    description: Fiat-to-crypto payment session creation
  - name: Payout Bank Accounts
    description: Allow-listing beneficiary bank accounts as pay-out destinations
  - name: Payout Routes
    description: Account-scoped routes pairing a beneficiary bank account with a crypto deposit asset
  - name: Payouts
    description: Crypto-to-fiat pay-outs over a registered route
  - name: Transfers
    description: Fiat and crypto transfer operations between accounts
  - name: Permits
    description: EIP-712 permit signature management for token approvals
  - name: Supported Assets
    description: Discover which chains and assets your tenant can settle in, and whether an account can use them yet.
  - name: Allowances
    description: Token allowance management for wallets
  - name: Webhooks
    description: Webhook registration and delivery testing

paths:
  # ==================== PARTY ENDPOINTS ====================

  /parties:
    get:
      tags: [Parties]
      summary: List all parties
      description: |
        **Requires scope:** `manage:parties`

        **Step 1 of the Finance API Flow**

        Retrieves all parties (Individuals and Organisations) in your system. Parties are the foundation of the Finance API - they represent the people or organizations that will hold accounts.

        **Use this endpoint to:**
        - View all registered customers and organizations
        - Search for existing parties before creating accounts
        - Audit party records and their KYC/KYB status

        **Next Step:** Use the party ID to create an account.
      operationId: listParties
      parameters:
        - name: partyType
          in: query
          description: Filter by party type
          schema:
            $ref: '#/components/schemas/PartyType'
          example: INDIVIDUAL
        - name: status
          in: query
          description: Filter by party status
          schema:
            $ref: '#/components/schemas/PartyStatus'
          example: ACTIVE
        - name: externalId
          in: query
          description: Filter by external ID (exact match)
          schema:
            type: string
          example: user-12345
        - $ref: '#/components/parameters/SortOn'
        - $ref: '#/components/parameters/SortOrder'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
      responses:
        '200':
          description: List of parties
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartyListResponse'
              example:
                success: true
                result:
                  - id: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                    externalId: user-12345
                    partyType: INDIVIDUAL
                    status: ACTIVE
                    firstName: Jane
                    lastName: Doe
                    createdAt: '2026-01-15T09:30:00Z'
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 1
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      tags: [Parties]
      summary: Create a new party
      description: |
        **Requires scope:** `manage:parties`

        **Step 1 of the Finance API Flow**

        Creates a new party (Individual or Organisation). This is the first step in onboarding a customer or organization to the Finance platform.

        **Party Types:**
        - **INDIVIDUAL**: Natural persons. Requires firstName, lastName.
        - **ORGANISATION**: Companies or businesses. Requires name, optionally vatNumber.

        **Next Step:** Use the returned party ID to create an account.
      operationId: createParty
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePartyRequest'
            example:
              partyType: INDIVIDUAL
              externalId: user-12345
              firstName: Jane
              lastName: Doe
              address:
                addressLine1: 1 Example Street
                city: Amsterdam
                postalCode: 1011AB
                country: NL
      responses:
        '201':
          description: Party created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyResponse'
              example:
                success: true
                result:
                  id: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  externalId: user-12345
                  partyType: INDIVIDUAL
                  status: ACTIVE
                  firstName: Jane
                  lastName: Doe
                  address:
                    addressLine1: 1 Example Street
                    city: Amsterdam
                    postalCode: 1011AB
                    country: NL
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
                  version: 0
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /parties/{partyId}:
    get:
      tags: [Parties]
      summary: Get party details
      description: |
        **Requires scope:** `manage:parties`

        Retrieves detailed information about a specific party by ID.

        **Returns:**
        - Complete party information including personal/organization details
        - Current KYC/KYB status
        - Address information
        - Party status (ACTIVE, SUSPENDED, BLOCKED)
      operationId: getParty
      parameters:
        - $ref: '#/components/parameters/PartyId'
      responses:
        '200':
          description: Party details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyResponse'
              example:
                success: true
                result:
                  id: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  externalId: user-12345
                  partyType: INDIVIDUAL
                  status: ACTIVE
                  firstName: Jane
                  lastName: Doe
                  kycStatus: VERIFIED
                  address:
                    addressLine1: 1 Example Street
                    city: Amsterdam
                    postalCode: 1011AB
                    country: NL
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
                  version: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

    patch:
      tags: [Parties]
      summary: Update party details
      description: |
        **Requires scope:** `manage:parties`

        Updates an existing party's information. Only provided fields will be updated.

        **For Individuals:** Can update firstName, lastName, and address
        **For Organisations:** Can update name, vatNumber, and address

        **Optimistic locking:** You must send the current `version` of the party. If the
        version is stale the request fails with `409 concurrent-modification` — re-fetch and retry.

        **Note:** Cannot change partyType after creation. KYC/KYB status is managed separately.
      operationId: updateParty
      parameters:
        - $ref: '#/components/parameters/PartyId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePartyRequest'
            example:
              version: 0
              firstName: Janet
              lastName: Doe
      responses:
        '200':
          description: Party updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyResponse'
              example:
                success: true
                result:
                  id: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  externalId: user-12345
                  partyType: INDIVIDUAL
                  status: ACTIVE
                  firstName: Janet
                  lastName: Doe
                  address:
                    addressLine1: 1 Example Street
                    city: Amsterdam
                    postalCode: 1011AB
                    country: NL
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
                  version: 0
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

    delete:
      tags: [Parties]
      summary: Delete party
      description: |
        **Requires scope:** `manage:parties`

        Permanently deletes a party. Only allowed when:
        - Party has no associated accounts
        - Party is not in a blocked status
      operationId: deleteParty
      parameters:
        - $ref: '#/components/parameters/PartyId'
      responses:
        '204':
          description: Party deleted
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== IDENTITY VERIFICATION ENDPOINTS ====================

  /parties/{partyId}/verification:
    post:
      tags: [Parties]
      summary: Create a hosted verification link
      description: |
        **Requires scope:** `manage:parties`

        Mints a hosted Venly verification link for the party and returns the single URL matching the
        party's type — the KYC flow for `INDIVIDUAL`, the KYB flow for `ORGANISATION`. Hand the URL to
        the end-user, who completes verification on Venly's hosted page. There is no request body: the
        party's stored `partyType` decides the flow.

        The verdict arrives asynchronously and flips the party's `kycStatus`/`kybStatus` — poll
        [Get a party](/api-reference/Finance-API/parties/get-party-details) for the outcome.

        Links do not expire. Re-POSTing while a link is still live returns the **same** `verificationUrl`
        with `200` (no new session is minted); if the link was revoked,
        a fresh one is minted and returned with `201`.

        Requires the company's verification tenant to be provisioned. Treat `verificationUrl` as a
        credential — it grants access to the end-user's onboarding flow.
      operationId: createPartyVerificationLink
      parameters:
        - $ref: '#/components/parameters/PartyId'
      responses:
        '201':
          description: A fresh verification link was minted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyVerificationLinkResponse'
              example:
                success: true
                result:
                  partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  verificationUrl: https://onboard.venly.io/kyc?token=inv-tok-9f2c41ab
                  status: VERIFICATION_PENDING
        '200':
          description: The party's existing live verification link was reissued unchanged
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyVerificationLinkResponse'
              example:
                success: true
                result:
                  partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  verificationUrl: https://onboard.venly.io/kyc?token=inv-tok-9f2c41ab
                  status: VERIFICATION_PENDING
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            Conflict:
            - `verification-not-provisioned` — the company has no provisioned verification tenant
            - `party-already-verified` — the party's `kycStatus`/`kybStatus` is already `VERIFIED`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                success: false
                errors:
                  - code: party-already-verified
                    message: "Party is already VERIFIED; no verification link is needed."
        '500':
          description: |
            `identity-verification-rejected` — verification rejected the request itself.
            Not retryable; contact support.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: |
            `identity-verification-unavailable` — verification is temporarily unavailable.
            Transient; retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /parties/{partyId}/iv-verification:
    get:
      tags: [Parties]
      summary: Get verification linkage
      description: |
        **Requires scope:** `manage:parties`

        Returns the party's verification linkage: the current linkage
        `status` plus the durable `ivCaseReference` you can quote to support.

        Always returns `200` for an existing party — it never signals a verification *outcome*. Read
        the party's own `kycStatus`/`kybStatus` from
        [Get a party](/api-reference/Finance-API/parties/get-party-details) for the verdict.

        `ivCaseReference` is populated once the platform has returned a case reference and is **never
        cleared** afterwards — including on `FAILED` after a declined verdict. It is `null` only while
        no reference was ever returned. Read the field directly rather than inferring it from `status`.

        This reflects **party-level** linkage only, never your company's tenant provisioning state.
      operationId: getPartyIvVerification
      parameters:
        - $ref: '#/components/parameters/PartyId'
      responses:
        '200':
          description: The party's verification linkage
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyIvVerificationResponse'
              example:
                success: true
                result:
                  partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  ivCaseReference: iv-case-8842f19c
                  status: COMPLETED
                  linkedAt: '2026-07-14T09:12:44Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /parties/{partyId}/partner-terms:
    get:
      tags: [Parties]
      summary: Get a party's partner-terms consent state
      description: |
        **Requires scope:** `manage:parties`

        Returns whether this party's end customer still has to accept our partners' terms, and while they do,
        the link to hand them.

        Party-level and partner-agnostic: one `status` and one `consentUrl` covering **every** partner the party
        is onboarding to, however many that is. You never deal with partners individually.

        Each call returns the **current** state and link, so an expiring or re-issued link is never stale. If you
        prefer polling the plain party read instead, `partnerTermsStatus` on
        [Get party details](/api-reference/Finance-API/parties/get-party-details) carries the same state.

        Consent is the **final gate after KYC**: a party that is not yet KYC-verified answers `NOT_REQUIRED`
        with no URL, because there is nothing to accept yet.

        See [Partner-terms consent](/guides/finance/onboarding/partner-terms-consent).
      operationId: getPartyPartnerTerms
      parameters:
        - $ref: '#/components/parameters/PartyId'
      responses:
        '200':
          description: The party's consent state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyPartnerTermsResponse'
              example:
                success: true
                result:
                  partyId: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  status: REQUIRED
                  consentUrl: 'https://onboard.venly.io/partner-terms?token=cons-tok-9f3a2b'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          description: |
            `identity-verification-rejected` — verification refused the request itself. **Not
            retryable**; the request will keep failing until the underlying cause is resolved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: |
            `identity-verification-unavailable` — **retryable**. Either verification could not be
            reached, or it answered but the state could not be resolved safely: data was missing, or it reported
            terms outstanding without a link.

            This endpoint deliberately fails closed rather than guessing. A `503` never means "nothing
            outstanding" — retry with backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /parties/{partyId}/partner-terms/link:
    post:
      tags: [Parties]
      summary: Issue a partner-terms consent link up front
      description: |
        **Requires scope:** `manage:parties`

        Mints a fresh consent link so your end customer can accept every partner's terms **before** onboarding
        begins — before any of their data is sent to a partner.

        Use this when you would rather collect consent up front than wait for it to become a blocker. The hosted
        page shows **every currently-enabled partner's latest terms** in one pass, whether or not the party has
        been forwarded to any partner yet.

        Takes no request body.

        <Warning>
        Each call mints a new link and **supersedes any previous live link** for that party. Don't call it to
        "check" the state — a link you handed out earlier stops working. Read
        [the consent state](/api-reference/Finance-API/parties/get-a-partys-partner-terms-consent-state) for that.
        </Warning>

        The URL embeds a scoped, expiring token: treat it as a credential, not a public page. Once the customer
        accepts, the party's consent state reflects it.
      operationId: createPartyPartnerTermsLink
      parameters:
        - $ref: '#/components/parameters/PartyId'
      responses:
        '201':
          description: A consent link was issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyPartnerTermsLinkResponse'
              example:
                success: true
                result:
                  partyId: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  consentUrl: 'https://onboard.venly.io/partner-terms?token=cons-tok-4c81de'
                  expiresAt: '2026-08-31T09:14:52Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            `verification-not-provisioned` — verification is not enabled for your company, so no link can be
            issued. Ask Venly to enable it; retrying will not help.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: |
            `identity-verification-rejected` — verification refused the request. **Not retryable.**
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: |
            `identity-verification-unavailable` — **retryable**. Verification could not be reached
            or could not produce a link. Retry with backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ==================== ACCOUNT ENDPOINTS ====================

  /accounts:
    get:
      tags: [Accounts]
      summary: List all accounts
      operationId: listAccounts
      description: |
        **Requires scope:** `manage:accounts`
      parameters:
        - name: externalId
          in: query
          description: Filter by external ID (exact match)
          schema:
            type: string
          example: user-12345
        - name: status
          in: query
          description: Filter by account status
          schema:
            $ref: '#/components/schemas/AccountStatus'
          example: ACTIVE
        - $ref: '#/components/parameters/SortOn'
        - $ref: '#/components/parameters/SortOrder'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
      responses:
        '200':
          description: List of accounts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountListResponse'
              example:
                success: true
                result:
                  - id: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                    externalId: user-12345
                    name: Jane Doe — Main
                    kycStatus: VERIFIED
                    status: ACTIVE
                    createdAt: '2026-01-15T09:30:00Z'
                    version: 0
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 1
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      tags: [Accounts]
      summary: Create a new account
      description: |
        **Requires scope:** `manage:accounts`

        Creates a new account and auto-provisions a custodial wallet on the specified chain.

        **Party Association:**
        - Provide `partyId` to associate with an existing party
        - Provide `party` object to create a new party inline (Individual or Organisation)
        - The party is associated as ACCOUNT_HOLDER

        When creating a party inline, specify `partyType: INDIVIDUAL` with firstName/lastName,
        or `partyType: ORGANISATION` with name/vatNumber.

        **SELF_CUSTODY companies** must supply the wallet `address`; VENLY_MANAGED companies
        leave it blank and Venly generates the wallet.

        **Forwarding an existing Sumsub verification:** if you already verified the end-user in your
        own Sumsub account, pass a share token as `party.sumsubToken` on the inline party. It is
        forwarded for verification inside the same transaction that creates the
        account. If the forward fails, **the whole request is rolled back** — no party, account, or
        wallet is persisted — so simply retry with a fresh token. The field is write-only and never
        returned. Only accepted here: `POST /parties` rejects it with `sumsub-token-not-supported`.

        See [Onboarding & Verification](/guides/finance/onboarding/lifecycle) for both routes.
      operationId: createAccount
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAccountRequest'
            example:
              externalId: user-12345
              name: Jane Doe — Main
              chain: BASE
              address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'
              partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
      responses:
        '201':
          description: Account created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleAccountResponse'
              example:
                success: true
                result:
                  id: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  externalId: user-12345
                  name: Jane Doe — Main
                  kycStatus: VERIFICATION_PENDING
                  status: ACTIVE
                  createdAt: '2026-01-15T09:30:00Z'
                  version: 0
        '400':
          description: |
            Invalid request. Possible error codes:
            - Missing required fields, or `chain` not in the company's supported chains
            - The company has no supported assets configured for the requested chain
            - `INVALID_ADDRESS_FORMAT` — malformed wallet address, or `address` missing for a SELF_CUSTODY company
            - `partyId` and `party` supplied together (mutually exclusive)
            - `kyc-status-not-settable` — `kycStatus` `VERIFIED` or `REJECTED` supplied (not client-settable)
            - `kyc-not-required-not-allowed` — `kycStatus: NOT_REQUIRED` supplied but no Venly admin has enabled the tenant-managed KYC flag on your company tenant
            - `verification-kyc-only` — `party.sumsubToken` supplied but `party.partyType` is not `INDIVIDUAL`
            - `verification-self-custody-only` — `party.sumsubToken` supplied but your company's wallet type is not `SELF_CUSTODY`
            - `verification-not-provisioned` — `party.sumsubToken` supplied but verification is not provisioned for your company
            - `SUMSUB_TOKEN_NOT_REDEEMABLE` — the share token is expired or already used. The whole request is rolled back; retry with a fresh token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                success: false
                errors:
                  - code: SUMSUB_TOKEN_NOT_REDEEMABLE
                    message: "The supplied sumsubToken is expired or already used. Retry with a fresh token."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: '`party-not-found` — the supplied `partyId` does not exist or belongs to another company.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Conflict. Possible error codes:
            - `account-already-exists` — an account with this `externalId` already exists for your company
            - `card-provider-already-linked` — the supplied `cardProviderReference` is already linked to another account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: |
            Server error, including `identity-verification-rejected` when verification rejects a
            forwarded `sumsubToken`. The whole request is rolled back — no account is created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: |
            `identity-verification-unavailable` — the `sumsubToken` forward could not reach the verification
            platform (transient). The whole request is rolled back; retry with a fresh token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /accounts/{accountId}:
    get:
      tags: [Accounts]
      summary: Get account details
      operationId: getAccount
      description: |
        **Requires scope:** `manage:accounts`
      parameters:
        - $ref: '#/components/parameters/AccountId'
      responses:
        '200':
          description: Account details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleAccountResponse'
              example:
                success: true
                result:
                  id: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  externalId: user-12345
                  name: Jane Doe — Main
                  kycStatus: VERIFIED
                  status: ACTIVE
                  createdAt: '2026-01-15T09:30:00Z'
                  version: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/party-roles:
    get:
      tags: [Accounts]
      summary: List party roles for an account
      description: |
        **Requires scope:** `manage:accounts`

        Returns all parties associated with this account and their roles.
      operationId: listPartyRoles
      parameters:
        - $ref: '#/components/parameters/AccountId'
      responses:
        '200':
          description: List of party roles
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartyRoleListResponse'
              example:
                success: true
                result:
                  - partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                    roleType: ACCOUNT_HOLDER
                    status: ACTIVE
                    createdAt: '2026-01-15T09:30:00Z'
                    updatedAt: '2026-01-15T09:30:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      tags: [Accounts]
      summary: Add a party to an account
      description: |
        **Requires scope:** `manage:accounts`

        Associates an existing party with this account in a specific role.
        A party can only have one role per account.
      operationId: addPartyRole
      parameters:
        - $ref: '#/components/parameters/AccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddPartyRoleRequest'
            example:
              partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
              roleType: ACCOUNT_HOLDER
      responses:
        '201':
          description: Party role added
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePartyRoleResponse'
              example:
                success: true
                result:
                  partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  roleType: ACCOUNT_HOLDER
                  status: ACTIVE
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/party-roles/{partyId}:
    delete:
      tags: [Accounts]
      summary: Remove a party from an account
      description: |
        **Requires scope:** `manage:accounts`

        Removes a party's role from this account.
        Cannot remove the last ACCOUNT_HOLDER from an account.
      operationId: removePartyRole
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - name: partyId
          in: path
          required: true
          description: The party ID to remove from the account
          schema:
            type: string
            format: uuid
          example: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
      responses:
        '204':
          description: Party role removed
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== WALLET ENDPOINTS ====================

  /accounts/{accountId}/wallets:
    get:
      tags: [Wallets]
      summary: List wallets for an account
      description: |
        **Requires scope:** `manage:accounts`

        Lists the custodial wallets bound to an account, each with its per-asset balances
        (total / available / reserved) and AML screening status.
      operationId: listWallets
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
      responses:
        '200':
          description: List of wallets
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletListResponse'
              example:
                success: true
                result:
                  - id: 9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f
                    chain: BASE
                    type: SELF_CUSTODY
                    address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'
                    balances:
                      - asset: USDC
                        contractAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
                        amount:
                          total: '125.00'
                          available: '100.00'
                          reserved: '25.00'
                    amlStatus: APPROVED
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 1
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== SUPPORTED ASSET ENDPOINTS ====================

  /supported-assets:
    get:
      tags: [Supported Assets]
      summary: List the assets your tenant can settle in
      description: |
        **Requires any one of:** `manage:transfers` or `manage:pay-in-sessions` or `manage:accounts`

        Returns every chain/asset pair enabled for your tenant. Read this instead of hard-coding a
        list — the set is configured per tenant and can change without an API version bump.

        Use it to populate an asset picker, and to learn the `chain` + `asset` pair to send when a
        fiat amount could settle in more than one asset. See
        [Supported chains and assets](/guides/finance/supported-chains-and-assets).
      operationId: listSupportedAssets
      responses:
        '200':
          description: The tenant's enabled assets
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportedAssetListResponse'
              example:
                success: true
                result:
                  - chain: BASE
                    cryptoCurrency: USDC
                    decimals: 6
                    contractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
                  - chain: AVALANCHE
                    cryptoCurrency: USDC
                    decimals: 6
                    contractAddress: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/supported-assets:
    get:
      tags: [Supported Assets]
      summary: List an account's assets and whether it can use them
      description: |
        **Requires scope:** `manage:accounts`

        The same assets as [List supported assets](/api-reference/Finance-API/supported-assets/list-supported-assets),
        each annotated with a `permitStatus` telling you whether **this account** can move that asset
        yet.

        Check this before offering an asset to an end-user. An asset the tenant has enabled is not
        automatically usable by every account — the account's wallet needs an allowance in place
        first, and on self-custody that needs the customer to act.

        `permitStatus` is derived per request, never stored.
      operationId: listAccountSupportedAssets
      parameters:
        - $ref: '#/components/parameters/AccountId'
      responses:
        '200':
          description: The account's assets with derived usability
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountSupportedAssetListResponse'
              example:
                success: true
                result:
                  - chain: BASE
                    cryptoCurrency: USDC
                    decimals: 6
                    contractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
                    permitStatus: READY
                  - chain: AVALANCHE
                    cryptoCurrency: USDC
                    decimals: 6
                    contractAddress: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'
                    permitStatus: ACTION_REQUIRED
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== VIRTUAL BANK ACCOUNT ENDPOINTS ====================

  /accounts/{accountId}/virtual-bank-accounts:
    get:
      tags: [Virtual Bank Accounts]
      summary: List virtual bank accounts
      operationId: listVirtualBankAccounts
      description: |
        **Requires scope:** `manage:accounts`
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/SortOn'
        - $ref: '#/components/parameters/SortOrder'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
      responses:
        '200':
          description: List of virtual bank accounts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VirtualBankAccountListResponse'
              example:
                success: true
                result:
                  - id: 4d5e6f70-8192-4a3b-9c4d-5e6f7081920a
                    accountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                    bankAccountType: EUR_SEPA
                    name: EUR Payouts
                    status: ACTIVE
                    currency: EUR
                    targetCryptocurrency: USDC
                    iban: DE89370400440532013000
                    bic: DEUTDEDB
                    bankName: Example Bank
                    beneficiaryName: Jane Doe
                    referenceCode: VFY-7K2Q-931
                    createdAt: '2026-01-15T09:30:00Z'
                    updatedAt: '2026-01-15T09:30:00Z'
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 1
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      tags: [Virtual Bank Accounts]
      summary: Create a virtual bank account
      description: |
        **Requires scope:** `manage:accounts`

        Creates a virtual bank account for the specified account.

        Incoming fiat payments to these rails are automatically converted to the target
        cryptocurrency and delivered on-chain.

        **Requires** the account to be KYC `VERIFIED`.

        The rail is resolved from the requested currency pair and your tenant's enabled pay-in
        configuration:

        | Lane | Returns | Notes |
        |---|---|---|
        | **EUR → SEPA** | `iban` + `bic` | `depositRails` may be populated asynchronously shortly after creation, so the account can come back `PENDING` first. |
        | **USD → ACH** | `accountNumber` + `routingNumber` | Returned synchronously. Requires the tenant to be onboarded for the USD lane with an approved KYB recording. USD accounts typically expose ACH, WIRE, RTP and SWIFT in `depositRails`. |

        Read `depositRails` for the complete per-rail instruction set a payer's bank needs. The
        single-rail summary fields (`iban`, `bic`, `accountNumber`, `routingNumber`) always describe
        the primary rail.

        **Wallet-ownership proof:** on some setups the converted funds settle straight to the
        customer's own self-custody wallet, and the customer must prove they control it first. Call
        [prepare](/api-reference/Finance-API/virtual-bank-accounts/prepare-a-wallet-ownership-proof)
        to find out: if a proof is required, have the customer sign the returned message verbatim
        with the destination wallet and submit it here in the `ownershipProof` block. Omitting it
        when it is required yields `400 ownership-proof-required`; where it is not required the
        field is ignored.

        **Idempotency:** `idempotencyKey` is required. A replay with the same key and an identical
        body returns the original result; the same key with a different body is rejected with
        `422 idempotency-conflict`.
      operationId: createVirtualBankAccount
      parameters:
        - $ref: '#/components/parameters/AccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateVirtualBankAccountRequest'
            example:
              name: EUR Payouts
              inCurrency: EUR
              targetCryptocurrency: USDC
              idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
      responses:
        '201':
          description: Virtual bank account created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleVirtualBankAccountResponse'
              example:
                success: true
                result:
                  id: 4d5e6f70-8192-4a3b-9c4d-5e6f7081920a
                  accountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  bankAccountType: EUR_SEPA
                  name: EUR Payouts
                  status: ACTIVE
                  currency: EUR
                  targetCryptocurrency: USDC
                  iban: DE89370400440532013000
                  bic: DEUTDEDB
                  bankName: Example Bank
                  beneficiaryName: Jane Doe
                  referenceCode: VFY-7K2Q-931
                  depositRails:
                    - railType: SEPA
                      iban: DE89370400440532013000
                      bic: DEUTDEDB
                      bankName: Example Bank
                      beneficiaryName: Jane Doe
                      paymentReference: VFY-7K2Q-931
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
        '400':
          description: |
            Invalid request. Possible error codes:
            - `account-not-active` / `kyc-not-verified` / `company-not-active` — account or company state does not allow creation
            - `duplicate-virtual-bank-account` — an active account already exists for this wallet + currency pair
            - `tenant-not-onboarded-for-lane` — the requested lane is not enabled for your tenant
            - `tenant-kyb-not-approved` — your tenant's recorded KYB status is not approved
            - `unsupported-asset` — `targetCryptocurrency` is not a supported asset for your company
            - `no-suitable-provider` — no enabled provider supports the requested currency pair
            - `party-required` / `party-name-required` — the account has no linked account-holder party, or that party has no legal name
            - `account-holder-unresolved` — the account does not resolve to exactly one active account-holder party
            - `ownership-proof-required` — this account requires a signed wallet-ownership proof, but no `ownershipProof` block was supplied
            - `invalid-proof-message` — `ownershipProof.message` is malformed or does not reference the wallet address and embedded token
            - `signature-mismatch` — `ownershipProof.signature` does not recover the supplied wallet address
            - `validation-error` — the provider rejected the request as invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Not found. Possible error codes:
            - `account-not-found` — no such account for your company
            - `wallet-not-found` — the `ownershipProof` wallet address/blockchain is not this account's self-custody wallet on that chain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Conflict. Possible error codes:
            - `idempotency-conflict` — a concurrent request with the same `idempotencyKey` is still processing
            - `partner-onboarding-pending` — the account holder's verification is still pending. **Retryable** once approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: |
            Unprocessable. Possible error codes:
            - `idempotency-conflict` — the key was reused with a different body, or replays a failed attempt (use a new key)
            - `ambiguous-lane` — multiple providers match the currency pair and no lane preference is configured to break the tie
            - `partner-onboarding-rejected` — the account holder's verification was rejected or revoked; provisioning cannot proceed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: |
            Server error, including `identity-verification-rejected` when verification
            rejected the account-holder onboarding request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: |
            Temporarily unavailable; retry later. The bank account provider is unreachable or
            rate-limited, or `identity-verification-unavailable` — verification could not
            be reached to onboard the account holder.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /accounts/{accountId}/virtual-bank-accounts/prepare:
    post:
      tags: [Virtual Bank Accounts]
      summary: Prepare a wallet-ownership proof
      description: |
        **Requires scope:** `manage:accounts`

        Returns the exact message a self-custody customer must sign to prove they control the
        destination wallet of a virtual bank account.

        This step is stateless: nothing is persisted, there is no preparation id and no expiry, so two
        calls for the same account and wallet return an equivalent message. The customer signs the
        `message` **verbatim and blind** with the destination wallet's own key — it embeds an opaque
        provider token, so do not reformat, re-encode, or reconstruct it.

        Submit the message plus signature in the `ownershipProof` block of
        [Create a virtual bank account](/api-reference/Finance-API/virtual-bank-accounts/create-a-virtual-bank-account).

        Not every account needs this step — call it to find out. Where no proof is required the
        call answers `400 ownership-proof-not-applicable` and the account can be created directly.

        Only an externally-owned account can sign: the scheme is EIP-191, and ERC-1271 contract
        signatures are not supported.
      operationId: prepareVirtualBankAccountOwnershipProof
      parameters:
        - $ref: '#/components/parameters/AccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PrepareOwnershipProofRequest'
            example:
              walletAddress: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'
              blockchain: ETHEREUM
      responses:
        '200':
          description: The assembled message to sign
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePrepareOwnershipProofResponse'
              example:
                success: true
                result:
                  walletAddress: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'
                  blockchain: ETHEREUM
                  message: "I confirm ownership of 0x71C7656EC7ab88b098defB751B7401B5f6d8976F on 2026-07-09 [tok:9f2c41ab...]"
                  signedOnUtc: '2026-07-09'
        '400':
          description: |
            Invalid request. Possible error codes:
            - `validation-error` — `walletAddress` or `blockchain` is missing
            - `invalid-request` — unsupported `blockchain`
            - `ownership-proof-not-applicable` — this account does not require a customer-signed wallet-ownership proof
            - `account-holder-unresolved` — the account does not resolve to exactly one active account-holder party
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Not found. Possible error codes:
            - `account-not-found` — no such account for your company
            - `wallet-not-found` — `walletAddress` is not this account's self-custody wallet on `blockchain`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: '`partner-onboarding-pending` — the account holder''s verification is still pending. **Retryable** once approved.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: '`partner-onboarding-rejected` — the account holder''s verification was rejected or revoked; provisioning cannot proceed.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: 'Server error, including `identity-verification-rejected`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: '`identity-verification-unavailable` — verification is temporarily unavailable. Retry later.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /accounts/{accountId}/virtual-bank-accounts/{virtualBankAccountId}:
    get:
      tags: [Virtual Bank Accounts]
      summary: Get virtual bank account details
      operationId: getVirtualBankAccount
      description: |
        **Requires scope:** `manage:accounts`
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/VirtualBankAccountId'
      responses:
        '200':
          description: Virtual bank account details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleVirtualBankAccountResponse'
              example:
                success: true
                result:
                  id: 4d5e6f70-8192-4a3b-9c4d-5e6f7081920a
                  accountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  bankAccountType: EUR_SEPA
                  name: EUR Payouts
                  status: ACTIVE
                  currency: EUR
                  targetCryptocurrency: USDC
                  iban: DE89370400440532013000
                  bic: DEUTDEDB
                  bankName: Example Bank
                  beneficiaryName: Jane Doe
                  referenceCode: VFY-7K2Q-931
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== PAYMENT SESSION ENDPOINTS ====================

  /accounts/{accountId}/fiat-to-crypto/payment-sessions:
    post:
      tags: [Fiat-to-crypto Payment Sessions]
      summary: Create a fiat-to-crypto payment session
      description: |
        **Requires scope:** `manage:pay-in-sessions`

        Creates a hosted pay-in session. The response includes a `paymentUrl` to redirect the
        payer to; on completion the incoming fiat is converted to the chosen cryptocurrency and
        credited to the account's wallet. `callbackUrl` and a UUID `idempotencyKey` are required.

        Requires the `manage:pay-in-sessions` role, an `ACTIVE` company and account, and a KYC
        `VERIFIED` account. The provider is resolved internally from the currency pair and your
        tenant's enabled pay-in configuration — the request never names one.

        Track the session's progress through `status`, which advances
        `CREATED → PENDING_PAYMENT → PAYMENT_RECEIVED → CONVERTING → MINTING → COMPLETED`. Terminal
        failure states are `FAILED`, `EXPIRED` and `CANCELLED`; a received payment that cannot be
        delivered may instead go `REFUNDING → REFUNDED`. `blockchainTxId` is populated once the
        on-chain deposit is broadcast.

        There is no endpoint to read a session back — subscribe to your `callbackUrl` (or register a
        [webhook](/api-reference/Finance-API/webhooks/list-webhooks)) for status transitions.
      operationId: createPayInSession
      parameters:
        - $ref: '#/components/parameters/AccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePayInSessionRequest'
            example:
              inAmount: '100.00'
              inCurrency: EUR
              outCryptocurrency: USDC
              callbackUrl: https://example.com/webhooks/pay-in
              successRedirectUrl: https://example.com/pay-in/success
              failureRedirectUrl: https://example.com/pay-in/failure
              externalRef: order-67890
              idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
      responses:
        '201':
          description: Payment session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePaymentSessionResponse'
              example:
                success: true
                result:
                  id: 5e6f7081-92a3-4b4c-8d5e-6f708192a3b4
                  accountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  status: CREATED
                  inAmount: 100.0
                  inCurrency: EUR
                  outCryptocurrency: USDC
                  paymentUrl: https://pay.example.com/session/5e6f708192a3b4c8
                  externalRef: order-67890
                  walletId: 9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f
                  blockchainTxId:
                  cancellable: true
                  expiresAt: '2026-01-15T10:30:00Z'
                  metadata:
                    orderId: '67890'
                  idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:00Z'
        '400':
          description: |
            Bad request. A field-validation failure returns one error entry per invalid field, coded
            `<field>.validation` — e.g. `inAmount.validation`, `callbackUrl.validation`,
            `idempotencyKey.validation`. Other possible error codes:
            - `no-suitable-provider` — no enabled provider supports this currency combination
            - `company-not-active` — your company is not `ACTIVE`
            - `account-not-active` — the account is not `ACTIVE`
            - `idempotency-key-conflict` — the `idempotencyKey` is already used by a different account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                success: false
                errors:
                  - code: no-suitable-provider
                    message: "No enabled pay-in provider supports EUR to USDC."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-in-sessions` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The account was not found, or the account has no wallet pair.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: |
            Unprocessable entity. Possible error codes:
            - `kyc-required` — the account's KYC status is not `VERIFIED`
            - `unsupported-asset` — `outCryptocurrency` is not a supported asset for your company
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                success: false
                errors:
                  - code: kyc-required
                    message: "Account {accountId} KYC status is not verified"
        '500':
          $ref: '#/components/responses/InternalServerError'
        '502':
          description: The payment provider returned an error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: The payment provider is not reachable. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ==================== TRANSFER ENDPOINTS ====================

  /accounts/{senderAccountId}/transfers/fiat:
    post:
      tags: [Transfers]
      summary: Create fiat transfer
      description: |
        **Requires scope:** `manage:transfers`

        Creates a fiat-denominated transfer from the sender account to a receiver account.
        The fiat amount is resolved to the underlying crypto asset and settled on-chain; the
        response carries a `fiatOrigin` block with the original currency, amount and exchange rate.
      operationId: createFiatTransfer
      parameters:
        - name: senderAccountId
          in: path
          required: true
          description: The account ID initiating the transfer
          schema:
            type: string
            format: uuid
          example: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFiatTransferInput'
            example:
              receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
              currency: USD
              amount: 25.0
              description: Invoice settlement
              idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
      responses:
        '201':
          description: Fiat transfer created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleTransferResponse'
              example:
                success: true
                result:
                  id: a1b2c3d4-e5f6-4789-9abc-def012345678
                  senderAccountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
                  chain: BASE
                  asset: USDC
                  amount: 25.0
                  fiatOrigin:
                    currency: USD
                    amount: 25.0
                    exchangeRate: 1
                  description: Invoice settlement
                  idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  status: COMPLETED
                  transactionHash: '0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:02Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Validation failed (e.g. receiver KYC not verified, insufficient balance)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{senderAccountId}/transfers/crypto:
    post:
      tags: [Transfers]
      summary: Create crypto transfer
      description: |
        **Requires scope:** `manage:transfers`

        Creates a cryptocurrency transfer from the sender account to a receiver account,
        moving crypto assets directly between accounts on the specified blockchain.
        Retries with the same `idempotencyKey` return the original transfer (no double-spend).
      operationId: createCryptoTransfer
      parameters:
        - name: senderAccountId
          in: path
          required: true
          description: The account ID initiating the transfer
          schema:
            type: string
            format: uuid
          example: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCryptoTransferInput'
            example:
              receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
              chain: BASE
              asset: USDC
              amount: 25.0
              description: Invoice settlement
              idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
      responses:
        '201':
          description: Crypto transfer created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleTransferResponse'
              example:
                success: true
                result:
                  id: a1b2c3d4-e5f6-4789-9abc-def012345678
                  senderAccountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
                  chain: BASE
                  asset: USDC
                  amount: 25.0
                  description: Invoice settlement
                  idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  status: COMPLETED
                  transactionHash: '0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:02Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Validation failed (e.g. receiver KYC not verified, insufficient balance/allowance)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/transfers:
    get:
      tags: [Transfers]
      summary: List transfers for account
      description: |
        **Requires scope:** `manage:transfers`

        Retrieves all transfers associated with an account (both sent and received, fiat and crypto).
        Can filter by account role (sender/receiver) and transfer status.
      operationId: listTransfers
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - name: accountRole
          in: query
          description: Filter by the account's role in the transfer
          schema:
            type: string
            enum: [SENDER, RECEIVER]
          example: SENDER
        - name: status
          in: query
          description: Filter by transfer status
          schema:
            $ref: '#/components/schemas/TransferStatus'
          example: COMPLETED
        - $ref: '#/components/parameters/SortOn'
        - $ref: '#/components/parameters/SortOrder'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
      responses:
        '200':
          description: List of transfers
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferListResponse'
              example:
                success: true
                result:
                  - id: a1b2c3d4-e5f6-4789-9abc-def012345678
                    senderAccountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                    receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
                    chain: BASE
                    asset: USDC
                    amount: 25.0
                    description: Invoice settlement
                    idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                    status: COMPLETED
                    transactionHash: '0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'
                    createdAt: '2026-01-15T09:30:00Z'
                    updatedAt: '2026-01-15T09:30:02Z'
                  - id: b2c3d4e5-f6a7-4890-abcd-ef0123456789
                    senderAccountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                    receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
                    chain: BASE
                    asset: USDC
                    amount: 10.0
                    description: Invoice settlement
                    idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                    status: COMPLETED
                    transactionHash: '0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'
                    createdAt: '2026-01-15T09:30:00Z'
                    updatedAt: '2026-01-15T09:30:02Z'
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 2
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/transfers/{transferId}:
    get:
      tags: [Transfers]
      summary: Get transfer details
      description: |
        **Requires scope:** `manage:transfers`

        Retrieves detailed information about a specific transfer. The account must be the
        sender or receiver of the transfer, otherwise a 404 is returned.
      operationId: getTransfer
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - name: transferId
          in: path
          required: true
          description: The transfer ID
          schema:
            type: string
            format: uuid
          example: a1b2c3d4-e5f6-4789-9abc-def012345678
      responses:
        '200':
          description: Transfer details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleTransferResponse'
              example:
                success: true
                result:
                  id: a1b2c3d4-e5f6-4789-9abc-def012345678
                  senderAccountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  receiverAccountId: c3b2a1f0-9d8c-4e3a-bf21-1a2b3c4d5e60
                  chain: BASE
                  asset: USDC
                  amount: 25.0
                  description: Invoice settlement
                  idempotencyKey: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  status: COMPLETED
                  transactionHash: '0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'
                  createdAt: '2026-01-15T09:30:00Z'
                  updatedAt: '2026-01-15T09:30:02Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== PERMIT ENDPOINTS ====================

  /accounts/{accountId}/wallets/{walletId}/permits:
    get:
      tags: [Permits]
      summary: Get permit messages for wallet
      description: |
        **Requires scope:** `manage:accounts`

        Retrieves the EIP-712 permit messages a self-custody wallet must sign to grant the orchestration wallet an allowance. Returns one entry per supported asset, each with a `supportedAssetId` and the `typedData` to sign.

        Sign `typedData` with the wallet **owner's** key (the signature must recover to `owner`), then submit it via POST.

        **Note:** Applies to SELF_CUSTODY companies only — VENLY_MANAGED wallets (and all escrow wallets) are permitted automatically by Venly.
      operationId: getPermitMessages
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/WalletId'
      responses:
        '200':
          description: List of permit messages
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermitMessageListResponse'
              example:
                success: true
                result:
                  - supportedAssetId: aabbccdd-1122-4334-9556-7788990011ab
                    asset: USDC
                    contractAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
                    status: CONFIRMED
                    typedData:
                      types:
                        EIP712Domain:
                          - name: name
                            type: string
                          - name: version
                            type: string
                          - name: chainId
                            type: uint256
                          - name: verifyingContract
                            type: address
                        Permit:
                          - name: owner
                            type: address
                          - name: spender
                            type: address
                          - name: value
                            type: uint256
                          - name: nonce
                            type: uint256
                          - name: deadline
                            type: uint256
                      primaryType: Permit
                      domain:
                        name: USDC
                        version: '2'
                        chainId: 84532
                        verifyingContract: '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
                      message:
                        owner: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'
                        spender: '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4'
                        value: '115792089237316195423570985008687907853269984665640564039457584007913129639935'
                        nonce: 0
                        deadline: '4102444800'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

    post:
      tags: [Permits]
      summary: Submit signed permit
      description: |
        **Requires scope:** `manage:accounts`

        Submits a signed EIP-712 permit for on-chain execution by the orchestration wallet (which pays the gas). The signature **must recover to the wallet `owner`** — otherwise the permit settles as `FAILED`.

        Returns HTTP 200 with the permit `status`: `SUBMITTED` while it settles on-chain, then `CONFIRMED`. Poll `GET .../permits` for the transition. When all of a wallet's permits are `CONFIRMED`, the wallet becomes `ACTIVE` and can send transfers/payments. Re-submitting an already-confirmed permit returns `409`.
      operationId: submitPermit
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/WalletId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitPermitRequest'
            example:
              supportedAssetId: aabbccdd-1122-4334-9556-7788990011ab
              signature:
                v: '28'
                r: '0x1111111111111111111111111111111111111111111111111111111111111111'
                s: '0x2222222222222222222222222222222222222222222222222222222222222222'
      responses:
        '200':
          description: Permit submitted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePermitResultResponse'
              example:
                success: true
                result:
                  id: 11aa22bb-33cc-4dd4-9ee5-66ff77aa88bb
                  supportedAssetId: aabbccdd-1122-4334-9556-7788990011ab
                  asset: USDC
                  contractAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
                  status: SUBMITTED
                  permitTxId: 0a9f8e7d-6c5b-4a39-8271-6a5b4c3d2e1f
                  skipped: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== ALLOWANCE ENDPOINTS ====================

  /accounts/{accountId}/wallets/{walletId}/allowances:
    get:
      tags: [Allowances]
      summary: Get wallet token allowances
      description: |
        **Requires scope:** `manage:accounts`

        Retrieves the current token allowances for a wallet — which tokens have been approved
        for spending by the orchestration wallet and the approved amounts.

        **Note:** Allowances apply to SELF_CUSTODY companies only.
      operationId: getWalletAllowances
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/WalletId'
        - name: asset
          in: query
          description: Filter by a specific asset (e.g. USDC)
          required: false
          schema:
            type: string
          example: USDC
      responses:
        '200':
          description: List of token allowances
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AllowanceListResponse'
              example:
                success: true
                result:
                  - asset:
                      name: USDC
                      contractAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
                      decimals: 6
                    allowance: '100.000000'
                    orchestrationWallet: '0x5B38Da6a701c568545dCfcB03FcB875f56beddC4'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/wallets/{walletId}/allowance-verification:
    post:
      tags: [Allowances]
      summary: Activate a wallet from its on-chain allowances
      description: |
        **Requires scope:** `manage:accounts`

        Activates a self-custody account wallet by reading the allowances it has already granted
        on-chain, instead of going through the permit flow.

        This is the route for a **smart-contract wallet** (for example a Safe). A contract wallet is
        controlled by its owners rather than a single key, so it cannot produce the one signature a
        permit needs — it grants the same permission with a plain `approve(spender, amount)`
        transaction. The allowance itself is the consent: only the wallet's controller could have
        granted it, so this endpoint reads public chain state and takes **no request body and no
        proof**.

        Both routes end in the same place: the wallet becomes `ACTIVE` once every supported asset is
        covered. See [Permits and allowances](/guides/finance/permits-and-allowances).

        **What happens when you call it**

        The wallet's allowances are read synchronously and every covered asset is marked confirmed.
        If all assets are covered the wallet returns `ACTIVE` immediately. If some are not, it
        returns `VERIFYING_ALLOWANCE` with a `verificationExpiresAt`, and Venly keeps re-checking
        until that deadline. Grant the missing allowances before it passes, or the wallet falls back
        to `PENDING` and you start again.

        <Note>
        `VERIFYING_ALLOWANCE` is a waiting room, not a usable state — every check that requires an
        active wallet treats it exactly like `PENDING`. Don't offer the wallet to an end-user until
        it reads `ACTIVE`.
        </Note>
      operationId: verifyWalletAllowances
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/WalletId'
      responses:
        '200':
          description: Verification outcome and the wallet's resulting status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleAllowanceVerificationResponse'
              example:
                success: true
                result:
                  walletId: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  status: VERIFYING_ALLOWANCE
                  verificationExpiresAt: '2026-08-20T15:04:05Z'
                  assets:
                    - supportedAssetId: 5d4c5b99-2ce8-40a1-bc1d-173dd400fa89
                      asset: USDC
                      contractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
                      allowanceSufficient: true
                      permitStatus: CONFIRMED
                    - supportedAssetId: 352fa5ad-ca00-4701-905f-744b867f6d5d
                      asset: EURC
                      contractAddress: '0x808456652fdb597867f38412077a9182bf77359f'
                      allowanceSufficient: false
                      permitStatus: PENDING
        '400':
          description: |
            Bad request. Notable codes:
            - `wallet-not-verifiable` — the wallet cannot enter verification: it is already `ACTIVE`, or it is `FROZEN`/`CLOSED`
            - `invalid-request` — the wallet is Venly-managed, so it activates through the permit flow instead
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== PAYOUT BANK ACCOUNT ENDPOINTS ====================

  /parties/{partyId}/payout-bank-accounts:
    post:
      tags: [Payout Bank Accounts]
      summary: Register a payout bank account
      description: |
        **Requires scope:** `manage:pay-outs`

        Allow-lists a beneficiary bank account as a pay-out destination for the recipient party.

        This is the **only** endpoint that accepts raw rail credentials. The account number is
        KMS-encrypted at rest immediately on persistence and is never returned — this response and
        every read path expose only `accountNumberLast4`. The ABA routing number is a public bank
        identifier and is returned in full.

        The owning party comes from the path, never the body. The initial supported rail is `US_ACH`,
        which requires `fiatCurrency: USD`.

        A newly registered account starts `PENDING` and becomes `ACTIVE` once activated for pay-outs.
      operationId: registerPayoutBankAccount
      parameters:
        - $ref: '#/components/parameters/PartyId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterPayoutBankAccountRequest'
            example:
              rail: US_ACH
              fiatCurrency: USD
              label: Jane's checking account
              accountHolderName: Jane Doe
              railDetails:
                accountNumber: '123456789012'
                abaRoutingNumber: '021000021'
                accountType: CHECKING
              bankName: Example National Bank
              bankAddress:
                street1: 270 Park Avenue
                city: New York
                region: NY
                postalCode: '10017'
                country: US
      responses:
        '201':
          description: Bank account registered (masked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutBankAccountResponse'
              example:
                success: true
                result:
                  id: a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6071
                  partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                  rail: US_ACH
                  fiatCurrency: USD
                  label: Jane's checking account
                  accountHolderName: Jane Doe
                  details:
                    accountNumberLast4: '9012'
                    abaRoutingNumber: '021000021'
                    accountType: CHECKING
                  bankName: Example National Bank
                  bankAddress:
                    street1: 270 Park Avenue
                    city: New York
                    region: NY
                    postalCode: '10017'
                    country: US
                  status: PENDING
                  createdAt: '2026-07-14T09:12:44Z'
                  updatedAt: '2026-07-14T09:12:44Z'
        '400':
          description: |
            Invalid request. Possible error codes:
            - `invalid-rail` — unsupported rail
            - `invalid-currency` — currency is inconsistent with the rail (`US_ACH` requires `USD`)
            - `missing-field` — a required field is absent
            - `invalid-account-number` — the account number is not 4–17 digits
            - `invalid-routing-number` — the ABA routing number failed its checksum
            - `invalid-account-type` — `accountType` is not `CHECKING` or `SAVINGS`
            - `invalid-bank-address` — a bank-address component is missing or `country` is not a valid ISO 3166-1 alpha-2 code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Party not found. Also returned for a party belonging to another company.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    get:
      tags: [Payout Bank Accounts]
      summary: List payout bank accounts
      description: |
        **Requires scope:** `manage:pay-outs`

        Returns a paginated list of the recipient party's allow-listed bank accounts. Account numbers
        are masked to the last 4 digits.
      operationId: listPayoutBankAccounts
      parameters:
        - $ref: '#/components/parameters/PartyId'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
        - $ref: '#/components/parameters/SortOn'
        - $ref: '#/components/parameters/SortOrder'
      responses:
        '200':
          description: List of payout bank accounts (masked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutBankAccountListResponse'
              example:
                success: true
                result:
                  - id: a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6071
                    partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                    rail: US_ACH
                    fiatCurrency: USD
                    label: Jane's checking account
                    accountHolderName: Jane Doe
                    details:
                      accountNumberLast4: '9012'
                      abaRoutingNumber: '021000021'
                      accountType: CHECKING
                    bankName: Example National Bank
                    status: ACTIVE
                    createdAt: '2026-07-14T09:12:44Z'
                    updatedAt: '2026-07-14T10:02:10Z'
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 1
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '400':
          description: Invalid pagination or sort parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /parties/{partyId}/payout-bank-accounts/{payoutBankAccountId}:
    get:
      tags: [Payout Bank Accounts]
      summary: Get a payout bank account
      description: |
        **Requires scope:** `manage:pay-outs`

        Returns a single payout bank account with its account number masked to the last 4 digits.
      operationId: getPayoutBankAccount
      parameters:
        - $ref: '#/components/parameters/PartyId'
        - $ref: '#/components/parameters/PayoutBankAccountId'
      responses:
        '200':
          description: Payout bank account (masked)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutBankAccountResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The bank account or party was not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # ==================== PAYOUT ROUTE ENDPOINTS ====================

  /accounts/{accountId}/payout-routes:
    post:
      tags: [Payout Routes]
      summary: Create a payout route
      description: |
        **Requires scope:** `manage:pay-outs`

        Creates an account-scoped pay-out route connecting an allow-listed
        [payout bank account](/api-reference/Finance-API/payout-bank-accounts/register-a-payout-bank-account)
        to a crypto deposit asset. A route fixes the beneficiary, the lane, the asset, and the fiat
        currency for every pay-out made over it.

        The provider is resolved internally — intersecting provider capability with your tenant's
        enablement — so the request never names one, and provider identity is never exposed.

        The **recipient gate** must pass: the bank account's party needs an `ACTIVE`
        `PAYOUT_RECIPIENT` role on this account with cleared KYC/KYB. Add the role via
        [Add a party to an account](/api-reference/Finance-API/accounts/add-a-party-to-an-account).
        A party may hold `PAYOUT_RECIPIENT` alongside `ACCOUNT_HOLDER` on the same account, which is
        the self-payout case.

        A route is created `PENDING` and progresses `REGISTERING → ACTIVE` as provider registration
        completes, or `REJECTED` if it fails terminally. Only `ACTIVE` routes are usable.
      operationId: createPayoutRoute
      parameters:
        - $ref: '#/components/parameters/AccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePayoutRouteRequest'
            example:
              payoutBankAccountId: a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6071
              depositAsset:
                chain: BASE
                name: USDC
      responses:
        '201':
          description: Route created (PENDING)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutRouteResponse'
              example:
                success: true
                result:
                  id: b2c3d4e5-f607-4819-a2b3-c4d5e6f70819
                  status: PENDING
                  depositAsset:
                    chain: BASE
                    name: USDC
                  fiatCurrency: USD
                  depositAddress:
                  createdAt: '2026-07-14T09:20:00Z'
                  updatedAt: '2026-07-14T09:20:00Z'
        '400':
          description: 'Invalid request — a required field is missing (`payoutBankAccountId`, `depositAsset.chain`, `depositAsset.name`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The account or payout bank account was not found. Also returned for cross-company resources.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: '`route-already-exists` — a route already exists for this account, bank account and deposit asset.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: |
            Unprocessable. Possible error codes:
            - `recipient-not-authorized` — the bank account's party has no `PAYOUT_RECIPIENT` role on this account, or its KYC/KYB is not cleared
            - `recipient-role-inactive` — the `PAYOUT_RECIPIENT` role exists but is `INACTIVE`
            - `unsupported-asset` — the deposit asset is not supported for your company
            - `unsupported-combination` — no single provider resolves for this bank account and deposit asset
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      tags: [Payout Routes]
      summary: List payout routes
      description: |
        **Requires scope:** `manage:pay-outs`

        Lists the account's pay-out routes. Provider identity is never exposed.

        `depositAddress` is included **only** for an `ACTIVE` route on a `SELF_CUSTODY` account — it is
        the address the customer sends to for a PUSH pay-out. It is null or omitted otherwise.
      operationId: listPayoutRoutes
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - name: payoutBankAccountId
          in: query
          required: false
          description: Restrict the list to routes for a single payout bank account
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of payout routes
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutRouteListResponse'
              example:
                success: true
                result:
                  - id: b2c3d4e5-f607-4819-a2b3-c4d5e6f70819
                    status: ACTIVE
                    depositAsset:
                      chain: BASE
                      name: USDC
                    fiatCurrency: USD
                    depositAddress: '0x9A7f4B2c1D3e5F6a8B0c2D4e6F8a0B2c4D6e8F0a'
                    createdAt: '2026-07-14T09:20:00Z'
                    updatedAt: '2026-07-14T09:24:31Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/payout-routes/{routeId}/ownership-proof/prepare:
    post:
      tags: [Payout Routes]
      summary: Prepare a pay-out route ownership proof
      description: |
        **Requires scope:** `manage:pay-outs`

        Returns the exact text the owner of a self-custody wallet must sign to prove they control it,
        so the pay-out route can finish registering.

        A route parks at `AWAITING_OWNERSHIP_PROOF` when its destination requires a Travel-Rule
        ownership proof and the source wallet is self-custody — Venly holds no key for that wallet,
        so only its owner can sign. See [Pay-outs](/guides/finance/payouts#proving-you-control-the-source-wallet).

        Stateless: nothing is stored, and there is no id or expiry. Calling it twice for the same
        route returns an equivalent message, so it is safe to repeat.

        Only an externally-owned account can sign — the scheme is EIP-191, and ERC-1271 contract
        signatures are not supported.
      operationId: preparePayoutRouteOwnershipProof
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/RouteId'
      responses:
        '200':
          description: The message to sign
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutOwnershipProofResponse'
              example:
                success: true
                result:
                  walletAddress: '0x7c6b9095Ae1E0F1D0b1B9c8A5f4E3d2C1b0A9f8E'
                  blockchain: BASE
                  message: "I confirm ownership of wallet 0x7c6b9095... on 20/08/2026"
                  signedOnUtc: '2026-08-20'
        '400':
          description: |
            Bad request. Notable codes:
            - `ownership-proof-not-applicable` — this route needs no owner-signed proof, or its source wallet is Venly-managed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/payout-routes/{routeId}/ownership-proof/complete:
    post:
      tags: [Payout Routes]
      summary: Submit a pay-out route ownership proof
      description: |
        **Requires scope:** `manage:pay-outs`

        Submits the signed message and resumes the route's registration. On success the route leaves
        `AWAITING_OWNERSHIP_PROOF` and continues to `ACTIVE` asynchronously — poll the route or
        [register a webhook](/guides/finance/webhooks) for the outcome.

        Send the `message` **exactly as `prepare` returned it**. It must match the canonical text for
        this route, which embeds the route's own wallet, and the signature must recover that wallet.
        A proof signed for a different wallet, route or tenant is rejected, and so is a message with
        extra text wrapped around the expected content.

        You do not have to call `prepare` first — this endpoint only cares that the message is the
        canonical one and the signature matches. The proof is never stored or logged: it is verified
        in memory and discarded.
      operationId: completePayoutRouteOwnershipProof
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/RouteId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompletePayoutOwnershipProofRequest'
            example:
              message: "I confirm ownership of wallet 0x7c6b9095... on 20/08/2026"
              signature: '0x9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a01b'
      responses:
        '200':
          description: The route, with registration resumed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutRouteResponse'
        '400':
          description: |
            Bad request. Notable codes:
            - `ownership-proof-not-applicable` — this route needs no owner-signed proof, or its source wallet is Venly-managed
            - `invalid-proof-message` — `message` is not the canonical text for this route
            - `signature-mismatch` — `signature` does not recover the route's wallet address
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== PAYOUT ENDPOINTS ====================

  /accounts/{accountId}/payouts:
    post:
      tags: [Payouts]
      summary: Request a payout
      description: |
        **Requires scope:** `manage:pay-outs`

        Requests a **PULL** pay-out against an `ACTIVE` payout route on this account: Venly sends crypto
        from the account wallet to the provider's deposit address using the up-front permit, and the
        provider pays out fiat to the beneficiary.

        The route fixes the beneficiary, lane, asset and fiat currency, so the request carries no fiat
        target, no provider and no funding mode — only the route, the crypto amount, and an idempotency
        key.

        This is a synchronous command with an asynchronous tail: the call validates, creates the pay-out
        in `REQUESTED`, and returns. The on-chain send and provider hand-off run afterwards, advancing
        `REQUESTED → SENDING → PROVIDER_PROCESSING → COMPLETED`.

        **Permit allowance and account-wallet balance are not pre-checked.** If either is insufficient,
        this call still returns `201` and the asynchronous send fails terminally — the pay-out moves to
        `FAILED` with a `failureReason`. Poll
        [Get a payout](/api-reference/Finance-API/payouts/get-a-payout) or subscribe to a webhook rather
        than treating `201` as success.

        **PUSH pay-outs are not created here.** They are created by the provider webhook flow after a
        self-custody customer sends to the route's `depositAddress` themselves, and appear in the same
        read endpoints with `fundingMode: PUSH`.

        **Idempotency:** `idempotencyKey` is required in the body and must be unique per company across
        all idempotent endpoints. A replay with the same key and an identical body returns the original
        result. Reuse with a different body, or after the original failed, is rejected with
        `422 idempotency-conflict`; reuse while the original is still in progress returns
        `409 idempotency-conflict`.
      operationId: requestPayout
      parameters:
        - $ref: '#/components/parameters/AccountId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePayoutRequest'
            example:
              payoutRouteId: b2c3d4e5-f607-4819-a2b3-c4d5e6f70819
              cryptoAmount: 100.00
              idempotencyKey: payout-2026-07-14-0001
      responses:
        '201':
          description: Payout created (REQUESTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutResponse'
              example:
                success: true
                result:
                  id: c3d4e5f6-0718-492a-b3c4-d5e6f708192a
                  accountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                  payoutRoute:
                    id: b2c3d4e5-f607-4819-a2b3-c4d5e6f70819
                    depositAsset:
                      chain: BASE
                      name: USDC
                    fiatCurrency: USD
                    depositAddress: '0x9A7f4B2c1D3e5F6a8B0c2D4e6F8a0B2c4D6e8F0a'
                    beneficiary:
                      id: a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6071
                      partyId: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
                      rail: US_ACH
                      label: Jane's checking account
                      accountHolderName: Jane Doe
                      bankName: Example National Bank
                      details:
                        accountNumberLast4: '9012'
                        abaRoutingNumber: '021000021'
                        accountType: CHECKING
                  rail: US_ACH
                  cryptoAmount: 100.00
                  settledFiatAmount:
                  fundingMode: PULL
                  status: REQUESTED
                  sendTxHash:
                  requestedAt: '2026-07-14T11:00:00Z'
                  completedAt:
                  failureReason:
        '400':
          description: |
            Validation failed — a required field is missing, `cryptoAmount` is not positive, or
            `account-not-active` (the account is not `ACTIVE`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The account was not found. Also returned for cross-company accounts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: '`idempotency-conflict` — the key was reused while the original request is still in progress.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: |
            Unprocessable. Possible error codes:
            - `route-unresolved` — the route does not resolve on this account
            - `route-not-active` — the route is not `ACTIVE`
            - `recipient-not-authorized` / `recipient-role-inactive` — the recipient gate failed
            - `unsupported-asset` — the route's lane is no longer supported for your tenant
            - `idempotency-conflict` — the key was reused with a different body, or after a failed original
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

    get:
      tags: [Payouts]
      summary: List payouts
      description: |
        **Requires scope:** `manage:pay-outs`

        Lists the account's pay-outs — both PULL (API-created) and PUSH (created from an observed
        customer deposit), distinguished by `fundingMode`.

        Returns compact summaries: the nested `payoutRoute` is route-only, with no beneficiary and no
        deposit address. Use [Get a payout](/api-reference/Finance-API/payouts/get-a-payout) for the
        full shape.

        Sortable on `createdAt`, `status` and `cryptoAmount` only.
      operationId: listPayouts
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - name: status
          in: query
          required: false
          description: Restrict the list to a single payout status
          schema:
            $ref: '#/components/schemas/PayoutStatus'
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Size'
        - $ref: '#/components/parameters/SortOn'
        - $ref: '#/components/parameters/SortOrder'
      responses:
        '200':
          description: Paginated list of payout summaries. Empty for pages beyond the data or filters with no matches.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutListResponse'
              example:
                success: true
                result:
                  - id: c3d4e5f6-0718-492a-b3c4-d5e6f708192a
                    accountId: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
                    payoutRoute:
                      id: b2c3d4e5-f607-4819-a2b3-c4d5e6f70819
                      depositAsset:
                        chain: BASE
                        name: USDC
                      fiatCurrency: USD
                      status: ACTIVE
                    rail: US_ACH
                    cryptoAmount: 100.00
                    settledFiatAmount: 99.50
                    fundingMode: PULL
                    status: COMPLETED
                    sendTxHash: '0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'
                    requestedAt: '2026-07-14T11:00:00Z'
                    completedAt: '2026-07-14T11:04:12Z'
                    failureReason:
                pagination:
                  pageNumber: 1
                  pageSize: 20
                  numberOfElements: 1
                  numberOfPages: 1
                  hasNextPage: false
                  hasPreviousPage: false
        '400':
          description: 'Invalid pagination or sort parameters. `sortOn` must be `createdAt`, `status` or `cryptoAmount`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /accounts/{accountId}/payouts/{payoutId}:
    get:
      tags: [Payouts]
      summary: Get a payout
      description: |
        **Requires scope:** `manage:pay-outs`

        Reads a single pay-out in the full shape: the nested `payoutRoute` includes the masked
        beneficiary bank account, plus `depositAddress` for an `ACTIVE` route on a `SELF_CUSTODY`
        account.

        `settledFiatAmount` is the fiat the provider actually paid out and is null until `COMPLETED`.
        `failureReason` is populated for `REJECTED`, `FAILED` and `RETURNED`.

        Internal and sensitive fields — the idempotency key, permit material, wallet ids, managed
        transaction ids and raw provider data — are never returned.
      operationId: getPayout
      parameters:
        - $ref: '#/components/parameters/AccountId'
        - $ref: '#/components/parameters/PayoutId'
      responses:
        '200':
          description: The payout
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SinglePayoutResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:pay-outs` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: The account or payout was not found. Also returned for cross-company or cross-account resources.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ==================== WEBHOOK ENDPOINTS ====================

  /webhooks:
    post:
      tags: [Webhooks]
      summary: Register a webhook
      description: |
        **Requires scope:** `manage:webhooks`

        Registers a webhook endpoint for your company so Venly can deliver asynchronous events —
        pay-in session transitions, pay-out status changes, verification verdicts and settlement
        outcomes — instead of you polling for them.

        The `url` must be an absolute `https://` endpoint.

        Choose an `authenticationMethod` so your endpoint can verify the caller: `API_KEY` (the key is
        injected into a header you name) or `BASIC_AUTHENTICATION`. Secrets (`apiKey`, `password`) are
        write-only — they are accepted here and never echoed back on any read response.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
            example:
              url: https://client.example/hooks/venly
              name: Production events
              authenticationMethod:
                type: API_KEY
                headerName: X-Webhook-Key
                apiKey: whsec_9f2c41ab7d
      responses:
        '201':
          description: Webhook registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleWebhookResponse'
              example:
                success: true
                result:
                  id: wh_8842f19c
                  url: https://client.example/hooks/venly
                  name: Production events
                  authenticationMethod:
                    type: API_KEY
                    headerName: X-Webhook-Key
                  status: ACTIVE
        '400':
          description: Invalid request — the `url` is not `https://`, or the authentication type is unknown.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:webhooks` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: Your company has reached the maximum number of registered webhooks.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Webhook management is temporarily unavailable. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    get:
      tags: [Webhooks]
      summary: List webhooks
      description: |
        **Requires scope:** `view:webhooks`

        Returns every webhook registered for your company. Authentication secrets are omitted.
        This list is not paginated.
      operationId: listWebhooks
      responses:
        '200':
          description: List of webhooks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookListResponse'
              example:
                success: true
                result:
                  - id: wh_8842f19c
                    url: https://client.example/hooks/venly
                    name: Production events
                    authenticationMethod:
                      type: API_KEY
                      headerName: X-Webhook-Key
                    status: ACTIVE
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `view:webhooks` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Webhook management is temporarily unavailable. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /webhooks/{webhookId}:
    get:
      tags: [Webhooks]
      summary: Get webhook details
      description: |
        **Requires scope:** `view:webhooks`

        Returns a single webhook registered for your company. Authentication secrets are omitted.
      operationId: getWebhook
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Webhook details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleWebhookResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `view:webhooks` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Webhook management is temporarily unavailable. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    put:
      tags: [Webhooks]
      summary: Update a webhook
      description: |
        **Requires scope:** `manage:webhooks`

        Replaces the configuration of an existing webhook. Same shape and validation as registration —
        this is a full replacement, not a partial patch, so send every field you want to keep.

        Authentication secrets are write-only and never echoed back.
      operationId: updateWebhook
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWebhookRequest'
            example:
              url: https://client.example/hooks/venly-v2
              name: Production events
              authenticationMethod:
                type: BASIC_AUTHENTICATION
                username: venly
                password: s3cr3t-rotated
      responses:
        '200':
          description: Webhook updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SingleWebhookResponse'
        '400':
          description: Invalid request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:webhooks` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Webhook management is temporarily unavailable. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      description: |
        **Requires scope:** `manage:webhooks`

        Removes a webhook registration. Venly stops delivering events to this endpoint.
      operationId: deleteWebhook
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '204':
          description: Webhook deleted
        '400':
          description: The webhook registration was rejected as invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:webhooks` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '503':
          description: Webhook management is temporarily unavailable. Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /webhooks/{webhookId}/ping:
    post:
      tags: [Webhooks]
      summary: Ping a webhook
      description: |
        **Requires scope:** `manage:webhooks`

        Queues a synthetic `PING` event for a single registered webhook so you can validate your
        endpoint end-to-end — including the signature and authentication header your handler expects.

        Delivery is **asynchronous**: a `200` means the ping was accepted for delivery, not that your
        endpoint has received it. Check your own logs to confirm arrival.
      operationId: pingWebhook
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Ping accepted for delivery
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BaseResponse'
              example:
                success: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Forbidden — the caller lacks the `manage:webhooks` role.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Webhook not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'


components:
  securitySchemes:
    OAuth2:
      type: oauth2
      description: |
        OAuth2 client credentials flow. Token endpoints:
        - Staging: https://login-staging.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token
        - Production: https://login.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token
      flows:
        clientCredentials:
          tokenUrl: https://login-staging.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token
          scopes: {}

  parameters:
    PartyId:
      name: partyId
      in: path
      required: true
      description: Unique party identifier
      schema:
        type: string
        format: uuid

      example: 7e3b9c2a-1f4d-4a8b-9c11-2d6e8f0a1b22
    AccountId:
      name: accountId
      in: path
      required: true
      description: Unique account identifier
      schema:
        type: string
        format: uuid

      example: b2a1f0e9-8c7d-4e3a-9f21-0a1b2c3d4e5f
    WalletId:
      name: walletId
      in: path
      required: true
      description: Unique wallet identifier
      schema:
        type: string
        format: uuid

      example: 9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f
    VirtualBankAccountId:
      name: virtualBankAccountId
      in: path
      required: true
      description: Unique virtual bank account identifier
      schema:
        type: string
        format: uuid

      example: 4d5e6f70-8192-4a3b-9c4d-5e6f7081920a
    PayoutBankAccountId:
      name: payoutBankAccountId
      in: path
      required: true
      description: Unique payout bank account identifier
      schema:
        type: string
        format: uuid

      example: a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6071
    PayoutId:
      name: payoutId
      in: path
      required: true
      description: Unique payout identifier
      schema:
        type: string
        format: uuid

      example: c3d4e5f6-0718-492a-b3c4-d5e6f708192a
    RouteId:
      name: routeId
      in: path
      required: true
      description: Unique pay-out route identifier
      schema:
        type: string
        format: uuid

      example: 7b683e87-4c73-4821-aa1d-52481805f40b
    WebhookId:
      name: webhookId
      in: path
      required: true
      description: Unique webhook identifier
      schema:
        type: string

      example: wh_8842f19c
    Page:
      name: page
      in: query
      description: Page number (1-based indexing)
      required: false
      schema:
        type: integer
        format: int32
        default: 1
        minimum: 1
      example: 1

    Size:
      name: size
      in: query
      description: Number of items per page
      required: false
      schema:
        type: integer
        format: int32
        default: 100
        minimum: 1
      example: 20

    SortOn:
      name: sortOn
      in: query
      description: Field to sort by
      required: false
      schema:
        type: string
        default: createdAt

      example: createdAt
    SortOrder:
      name: sortOrder
      in: query
      description: Sort direction
      required: false
      schema:
        type: string
        default: ASC
        enum:
          - ASC
          - DESC

      example: DESC
  responses:
    BadRequest:
      description: Request validation failed (400)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: invalid-request
                message: "The request contains invalid parameters."

    Unauthorized:
      description: Authentication required or failed (401)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: unauthenticated
                message: "Please authenticate to perform this action."

    Forbidden:
      description: Caller lacks the required authority/role (403)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: forbidden
                message: "You do not have permission to access this resource."

    NotFound:
      description: Resource not found (404)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: account-not-found
                message: "The requested resource was not found."

    Conflict:
      description: Resource conflict / optimistic-lock failure (409)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: concurrent-modification
                message: "This request has been modified by another user. Please refresh and try again."

    PaymentRequired:
      description: Funds or allowance insufficient to complete the operation (402)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: insufficient-funds
                message: "The account wallet balance is insufficient to complete this operation."

    UnprocessableEntity:
      description: >-
        Idempotency conflict — the key was reused with a different body, or replays an
        operation that originally failed (422)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: idempotency-conflict
                message: "This idempotency key was already used with a different request."

    InternalServerError:
      description: Unexpected server error (500)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            errors:
              - code: internal-error
                message: "An unexpected error occurred. Please try again later."

  schemas:
    # ==================== ENUMS ====================

    PartyType:
      type: string
      description: Type of party
      enum: [INDIVIDUAL, ORGANISATION]

    PartyStatus:
      type: string
      description: Status of a party
      enum: [ACTIVE, SUSPENDED, BLOCKED]

    PartyRoleType:
      type: string
      description: |
        Role type for the party-account relationship.
        - `ACCOUNT_HOLDER` — the party holds the account
        - `PAYOUT_RECIPIENT` — the party is an allowed pay-out destination for the account. Required
          (with cleared KYC/KYB) before a [payout route](/guides/finance/payouts) can be created for
          its bank account. A party may hold this alongside `ACCOUNT_HOLDER` on the same account, which
          is the self-payout case.
      enum: [ACCOUNT_HOLDER, PAYOUT_RECIPIENT]

    PartyRoleStatus:
      type: string
      description: Status of a party role
      enum: [ACTIVE, INACTIVE]

    AccountStatus:
      type: string
      description: Status of an account
      enum: [ACTIVE, SUSPENDED, CLOSED]

    WalletType:
      type: string
      description: Custody model of the wallet
      enum: [VENLY_MANAGED, SELF_CUSTODY]

    VirtualBankAccountStatus:
      type: string
      description: |
        Status of a virtual bank account.
        - `PENDING` — claimed, but the provider has not yet returned the deposit rails
        - `ACTIVE` — accepting fiat deposits
        - `CLOSED` — permanently closed
      enum: [PENDING, ACTIVE, CLOSED]

    BankAccountType:
      type: string
      description: |
        Rail family of a virtual bank account, resolved from the requested currency pair.
        - `EUR_SEPA` — Euro SEPA account, returning `iban` + `bic`
        - `USD_ACH` — US account, returning `accountNumber` + `routingNumber`

        Omitted while `status` is `PENDING`, before the provider has returned the rail type.
      enum: [EUR_SEPA, USD_ACH]

    AchAccountType:
      type: string
      description: Type of US-ACH bank account, supplied when registering a payout bank account.
      enum: [CHECKING, SAVINGS]

    DepositRailType:
      type: string
      description: |
        A single payment rail a virtual bank account is reachable over. USD accounts typically expose
        `ACH`, `WIRE`, `RTP` and `SWIFT` sharing the same account and routing number (`SWIFT` carries a
        BIC instead of a routing number); EUR accounts expose a single `SEPA` rail.
      enum: [ACH, WIRE, RTP, SWIFT, SEPA]

    BlockchainNetwork:
      type: string
      description: |
        Supported blockchain network. Availability per chain depends on your company's configured
        `supportedChains` — see [Supported chains and assets](/guides/finance/supported-chains-and-assets).
      enum: [AVALANCHE, BASE, ETHEREUM, POLYGON, SOLANA]

    Cryptocurrency:
      type: string
      description: Supported cryptocurrency / stablecoin asset
      enum: [USDC, EURC, USDT, USDS]

    FiatCurrency:
      type: string
      description: Supported fiat currencies. Virtual bank accounts currently support EUR only.
      enum: [EUR, GBP, USD]

    KycStatus:
      type: string
      description: |
        KYC verification status (Individuals & accounts).
        - `VERIFICATION_PENDING` — awaiting a verdict; money movement is blocked
        - `VERIFIED` — approved; all operations available
        - `REJECTED` — verification was declined
        - `NOT_REQUIRED` — **accounts only.** The tenant manages KYC itself, so Venly does not gate on its
          own verification. Settable only at account creation, and only once a **Venly admin has enabled the
          tenant-managed KYC flag** on the company tenant; otherwise the call fails with
          `kyc-not-required-not-allowed`. The flag is granted for testing rather than production use —
          production integrations verify via a hosted verification link or Sumsub token sharing.
      enum: [VERIFICATION_PENDING, VERIFIED, NOT_REQUIRED, REJECTED]

    KybStatus:
      type: string
      description: KYB verification status (Organisations)
      enum: [PENDING, VERIFIED, DENIED]

    AmlStatus:
      type: string
      description: |
        AML screening status of a wallet. A wallet must be `APPROVED` before it can move money.
        - `PENDING` — screening in progress
        - `APPROVED` — cleared
        - `FLAGGED` — flagged for manual review; money movement is blocked
        - `BLOCKED` — screening blocked the wallet; money movement is blocked
      enum: [PENDING, APPROVED, FLAGGED, BLOCKED]

    TransferStatus:
      type: string
      description: Status of a transfer
      enum: [PENDING, COMPLETED, FAILED]





    PermitStatus:
      type: string
      description: Status of an EIP-712 permit
      enum: [PENDING, SUBMITTED, CONFIRMED, FAILED]

    PaymentSessionStatus:
      type: string
      description: Status of a fiat-to-crypto payment session
      enum:
        - CREATED
        - PENDING_PAYMENT
        - PAYMENT_RECEIVED
        - CONVERTING
        - MINTING
        - COMPLETED
        - FAILED
        - EXPIRED
        - CANCELLED
        - REFUNDING
        - REFUNDED

    # ==================== BASE / SHARED ====================

    BaseResponse:
      type: object
      properties:
        success:
          type: boolean
          description: Indicates whether the request was successful

    Pagination:
      type: object
      description: Pagination metadata (top-level sibling of `result`)
      properties:
        pageNumber:
          type: integer
          format: int32
          description: Current page number (1-based)
        pageSize:
          type: integer
          format: int32
          description: Number of items per page
        numberOfElements:
          type: integer
          format: int64
          description: Total number of items across all pages
        numberOfPages:
          type: integer
          format: int32
          description: Total number of pages
        hasNextPage:
          type: boolean
          description: Whether there is a next page
        hasPreviousPage:
          type: boolean
          description: Whether there is a previous page

    ErrorResponse:
      type: object
      description: Error response wrapper
      properties:
        success:
          type: boolean
          description: Always false for error responses
          example: false
        errors:
          type: array
          description: List of errors that occurred
          items:
            $ref: '#/components/schemas/ErrorBody'
        result:
          type: object
          nullable: true
          description: Null or omitted when success is false

    ErrorBody:
      type: object
      description: Individual error details
      properties:
        code:
          type: string
          description: Machine-readable error code
          example: "invalid-request"
        message:
          type: string
          description: Human-readable error message
          example: "The request contains invalid parameters."


    Address:
      type: object
      description: Standardized address format used across all endpoints
      properties:
        addressLine1:
          type: string
          maxLength: 255
        addressLine2:
          type: string
          maxLength: 255
        city:
          type: string
          maxLength: 100
        state:
          type: string
          maxLength: 100
        postalCode:
          type: string
          maxLength: 20
        country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO 3166-1 alpha-2 country code

    # ==================== PARTY SCHEMAS ====================

    Party:
      type: object
      description: |
        A party represents an Individual or Organisation that can hold accounts.
        The partyType field determines which additional fields are present.
      properties:
        id:
          type: string
          format: uuid
        externalId:
          type: string
          description: External reference ID
        partyType:
          $ref: '#/components/schemas/PartyType'
        status:
          $ref: '#/components/schemas/PartyStatus'
        firstName:
          type: string
          description: First name (Individual only)
        lastName:
          type: string
          description: Last name (Individual only)
        kycStatus:
          $ref: '#/components/schemas/KycStatus'
        name:
          type: string
          description: Organisation name (Organisation only)
        vatNumber:
          type: string
          description: VAT number (Organisation only)
        kybStatus:
          $ref: '#/components/schemas/KybStatus'
        partnerTermsStatus:
          $ref: '#/components/schemas/PartnerTermsStatus'
        email:
          type: string
          format: email
          maxLength: 255
          nullable: true
          description: |
            Contact email for the party. Optional, but **supply a real one up front** for an individual on a
            self-custody account.

            When an account holder is onboarded to a partner their email is forwarded with the case. If the party
            has none, a synthetic `<case-id>@venlyfinance.com` address is used so onboarding still proceeds — and
            because the partner stores that email **write-once**, adding a real address later does **not** replace
            the synthetic one. There is no way to correct it afterwards.
        address:
          $ref: '#/components/schemas/Address'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        version:
          type: integer
          format: int64
          description: Optimistic-locking version, required when updating

    CreatePartyRequest:
      type: object
      required: [partyType]
      properties:
        partyType:
          $ref: '#/components/schemas/PartyType'
        externalId:
          type: string
          maxLength: 255
          description: Optional external reference ID
        firstName:
          type: string
          maxLength: 100
          description: Required for INDIVIDUAL parties
        lastName:
          type: string
          maxLength: 100
          description: Required for INDIVIDUAL parties
        name:
          type: string
          maxLength: 255
          description: Organisation name. Required for ORGANISATION parties
        vatNumber:
          type: string
          maxLength: 50
          description: VAT number. ORGANISATION parties only
        email:
          type: string
          format: email
          maxLength: 255
          nullable: true
          description: |
            Contact email for the party. Optional, but **supply a real one up front** for an individual on a
            self-custody account.

            When an account holder is onboarded to a partner their email is forwarded with the case. If the party
            has none, a synthetic `<case-id>@venlyfinance.com` address is used so onboarding still proceeds — and
            because the partner stores that email **write-once**, adding a real address later does **not** replace
            the synthetic one. There is no way to correct it afterwards.
        address:
          $ref: '#/components/schemas/Address'
        sumsubToken:
          type: string
          maxLength: 4096
          writeOnly: true
          description: |
            Optional Sumsub share token, forwarding a verification you already completed in your own
            Sumsub account. **Accepted only when creating a party inline via
            [Create an account](/api-reference/Finance-API/accounts/create-a-new-account)** —
            `POST /parties` rejects it with `400 sumsub-token-not-supported`.

            Requires `partyType: INDIVIDUAL` (KYC only), a `SELF_CUSTODY` company wallet type, and a
            provisioned verification tenant. Never persisted and never returned. If the forward fails,
            the entire account-creation transaction is rolled back — retry with a fresh token.

            See [Sumsub token sharing](/guides/finance/onboarding/sumsub-token-sharing).
      example:
        partyType: INDIVIDUAL
        firstName: "John"
        lastName: "Doe"
        address:
          addressLine1: "123 Main Street"
          city: "Amsterdam"
          postalCode: "1012AB"
          country: "NL"

    UpdatePartyRequest:
      type: object
      required: [version]
      description: |
        Update party details. Only fields applicable to the party type can be updated.
        For Individuals: firstName, lastName, address.
        For Organisations: name, vatNumber, address.
      properties:
        version:
          type: integer
          format: int64
          description: Current version of the party for optimistic locking
        firstName:
          type: string
          maxLength: 100
          description: First name (Individual only)
        lastName:
          type: string
          maxLength: 100
          description: Last name (Individual only)
        name:
          type: string
          maxLength: 255
          description: Organisation name (Organisation only)
        vatNumber:
          type: string
          maxLength: 50
          description: VAT number (Organisation only)
        email:
          type: string
          format: email
          maxLength: 255
          nullable: true
          description: |
            Contact email for the party. Optional, but **supply a real one up front** for an individual on a
            self-custody account.

            When an account holder is onboarded to a partner their email is forwarded with the case. If the party
            has none, a synthetic `<case-id>@venlyfinance.com` address is used so onboarding still proceeds — and
            because the partner stores that email **write-once**, adding a real address later does **not** replace
            the synthetic one. There is no way to correct it afterwards.
        address:
          $ref: '#/components/schemas/Address'

    SinglePartyResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/Party'

    PartyListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/PartyListItem'
            pagination:
              $ref: '#/components/schemas/Pagination'

    # ==================== PARTY ROLE SCHEMAS ====================

    PartyRole:
      type: object
      description: Represents the relationship between a Party and an Account
      properties:
        partyId:
          type: string
          format: uuid
        roleType:
          $ref: '#/components/schemas/PartyRoleType'
        status:
          $ref: '#/components/schemas/PartyRoleStatus'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    AddPartyRoleRequest:
      type: object
      required: [partyId, roleType]
      properties:
        partyId:
          type: string
          format: uuid
          description: ID of the party to add to the account
        roleType:
          $ref: '#/components/schemas/PartyRoleType'

    SinglePartyRoleResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PartyRole'

    PartyRoleListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              description: Party roles for the account (not paginated)
              items:
                $ref: '#/components/schemas/PartyRole'

    # ==================== ACCOUNT SCHEMAS ====================

    Account:
      type: object
      properties:
        id:
          type: string
          format: uuid
        externalId:
          type: string
        name:
          type: string
          description: Display name for the account
        kycStatus:
          $ref: '#/components/schemas/KycStatus'
        status:
          $ref: '#/components/schemas/AccountStatus'
        createdAt:
          type: string
          format: date-time
        version:
          type: integer
          format: int64
          description: Optimistic-locking version

    CreateAccountRequest:
      type: object
      required: [externalId, chain]
      description: |
        Create a new account. You can either:
        - Reference an existing party by providing `partyId`
        - Create a new party inline by providing the `party` object

        SELF_CUSTODY companies must also supply the wallet `address`.
      properties:
        externalId:
          type: string
          minLength: 1
          description: Unique external reference for this account
        name:
          type: string
          maxLength: 255
          minLength: 1
          description: Display name for the account
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        address:
          type: string
          description: Wallet address. Required for SELF_CUSTODY companies
        partyId:
          type: string
          format: uuid
          description: ID of an existing party to associate as account holder
        party:
          $ref: '#/components/schemas/CreatePartyRequest'
        cardProviderReference:
          $ref: '#/components/schemas/CardProviderReference'
        kycStatus:
          $ref: '#/components/schemas/KycStatus'
          description: |
            Optional initial KYC status. Only `NOT_REQUIRED` is settable — it declares that you verify
            end-users within your own compliance stack, so Venly does not gate money movement on its own
            verification.

            **This requires a Venly admin to enable the tenant-managed KYC flag on your company tenant.**
            You cannot switch it on yourself, and until it is enabled the call fails with
            `kyc-not-required-not-allowed`. In practice the flag is granted for testing rather than
            production use — production integrations verify via a hosted verification link or Sumsub token
            sharing.

            Supplying `VERIFIED` or `REJECTED` is rejected with `kyc-status-not-settable`. Omit the field
            for the normal flow.
      example:
        externalId: "user-12345"
        name: "John Doe Account"
        chain: "BASE"
        party:
          partyType: "INDIVIDUAL"
          firstName: "John"
          lastName: "Doe"
          address:
            addressLine1: "123 Main Street"
            city: "Amsterdam"
            postalCode: "1012AB"
            country: "NL"

    SingleAccountResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/Account'

    AccountListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/Account'
            pagination:
              $ref: '#/components/schemas/Pagination'

    # ==================== WALLET SCHEMAS ====================

    CryptoBalance:
      type: object
      description: Balance breakdown for a single asset, as decimal strings
      properties:
        total:
          type: string
        available:
          type: string
        reserved:
          type: string

    TokenBalance:
      type: object
      properties:
        asset:
          type: string
          description: Asset symbol (e.g. USDC)
        contractAddress:
          type: string
          description: ERC-20 contract address of the asset
        amount:
          $ref: '#/components/schemas/CryptoBalance'

    Wallet:
      type: object
      properties:
        id:
          type: string
          format: uuid
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        type:
          $ref: '#/components/schemas/WalletType'
        address:
          type: string
        balances:
          type: array
          items:
            $ref: '#/components/schemas/TokenBalance'
        amlStatus:
          $ref: '#/components/schemas/AmlStatus'

    WalletListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/Wallet'
            pagination:
              $ref: '#/components/schemas/Pagination'

    # ==================== VIRTUAL BANK ACCOUNT SCHEMAS ====================

    VirtualBankAccount:
      type: object
      description: |
        Virtual bank account for receiving fiat payments that are auto-converted to crypto.
        `EUR_SEPA` accounts return `iban`/`bic`; `USD_ACH` accounts return
        `accountNumber`/`routingNumber`. Read `depositRails` for the complete instruction set
        per rail — the summary fields below always describe the primary rail.
      properties:
        id:
          type: string
          format: uuid
        accountId:
          type: string
          format: uuid
        bankAccountType:
          $ref: '#/components/schemas/BankAccountType'
        name:
          type: string
          description: Display name for the bank account
        status:
          $ref: '#/components/schemas/VirtualBankAccountStatus'
        currency:
          $ref: '#/components/schemas/FiatCurrency'
          description: Fiat currency this account receives
        targetCryptocurrency:
          $ref: '#/components/schemas/Cryptocurrency'
          description: Cryptocurrency that incoming fiat is converted to
        iban:
          type: string
          description: IBAN for EUR_SEPA accounts
          example: "DE89370400440532013000"
        bic:
          type: string
          nullable: true
          description: BIC/SWIFT code (EUR rails)
          example: "DEUTDEDB"
        accountNumber:
          type: string
          nullable: true
          description: Account number (US rails)
          example: "8412009371"
        routingNumber:
          type: string
          nullable: true
          description: Routing number (US rails)
          example: "021000021"
        bankName:
          type: string
          nullable: true
          description: Name of the banking provider
        beneficiaryName:
          type: string
          nullable: true
          description: Name of the account beneficiary
        referenceCode:
          type: string
          nullable: true
          description: Unique reference code to include in payment transfers
        depositRails:
          type: array
          description: |
            Every payment rail this account is reachable over, each with the full instruction set a
            payer's bank needs. USD accounts typically expose ACH, WIRE, RTP and SWIFT sharing the
            same account and routing number; EUR accounts expose a single SEPA rail.
          items:
            $ref: '#/components/schemas/DepositRail'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    DepositRail:
      type: object
      description: One rail's complete deposit instructions for a virtual bank account.
      required: [railType]
      properties:
        railType:
          $ref: '#/components/schemas/DepositRailType'
        iban:
          type: string
          nullable: true
          description: International Bank Account Number (SEPA)
        bic:
          type: string
          nullable: true
          description: Bank Identifier Code (SEPA and SWIFT)
        accountNumber:
          type: string
          nullable: true
          description: Account number (US rails)
        routingNumber:
          type: string
          nullable: true
          description: Routing number (US rails; absent on SWIFT, which uses the BIC)
        bankName:
          type: string
          nullable: true
        bankAddress:
          type: string
          nullable: true
          description: The bank's postal address, required by wire and SWIFT senders
        beneficiaryName:
          type: string
          nullable: true
        beneficiaryAddress:
          type: string
          nullable: true
          description: The beneficiary's postal address, required by wire and SWIFT senders
        paymentReference:
          type: string
          nullable: true
          description: Reference the payer should attach to the transfer, when the rail requires one

    CreateVirtualBankAccountRequest:
      type: object
      description: |
        Request to create a virtual bank account. The rail is resolved from `inCurrency` and your
        tenant's enabled pay-in configuration (EUR → SEPA, USD → ACH).
      required:
        - name
        - inCurrency
        - targetCryptocurrency
        - idempotencyKey
      properties:
        name:
          type: string
          maxLength: 255
          description: Display name for the bank account
        inCurrency:
          type: string
          pattern: "^[A-Z]{3}$"
          description: Fiat currency to receive (ISO 4217). Determines the bank account type (EUR -> EUR_SEPA)
        targetCryptocurrency:
          type: string
          pattern: "^[A-Za-z0-9]{2,10}$"
          description: Cryptocurrency to convert incoming fiat payments to (e.g. USDC)
        idempotencyKey:
          type: string
          maxLength: 255
          description: Unique key to prevent duplicate operations on retry
        ownershipProof:
          $ref: '#/components/schemas/OwnershipProof'
      example:
        name: "My EUR Deposit Account"
        inCurrency: "EUR"
        targetCryptocurrency: "USDC"
        idempotencyKey: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

    OwnershipProof:
      type: object
      description: |
        Customer-signed wallet-ownership proof. Required where the virtual bank account settles to
        a self-custody account wallet that must prove control first — the destination is the
        customer's own wallet, so Venly cannot sign the proof itself.

        Obtain `message` from
        [prepare](/api-reference/Finance-API/virtual-bank-accounts/prepare-a-wallet-ownership-proof),
        have the customer sign it verbatim with the destination wallet, and submit it here. Ignored
        where no proof is required, so it is safe to send unconditionally.
      required: [walletAddress, blockchain, message, signature]
      properties:
        walletAddress:
          type: string
          maxLength: 128
          description: The destination self-custody wallet address the proof is for
        blockchain:
          type: string
          maxLength: 32
          description: The chain of the destination wallet (e.g. ETHEREUM)
        message:
          type: string
          maxLength: 1024
          description: The exact message returned by prepare, submitted verbatim
        signature:
          type: string
          maxLength: 512
          description: The customer's EIP-191 signature over `message`; must recover `walletAddress`

    PrepareOwnershipProofRequest:
      type: object
      description: Request to assemble the wallet-ownership-proof message for a virtual bank account.
      required: [walletAddress, blockchain]
      properties:
        walletAddress:
          type: string
          maxLength: 128
          description: The destination self-custody wallet address to prove ownership of
        blockchain:
          type: string
          maxLength: 32
          description: The chain of the destination wallet (e.g. ETHEREUM)

    PrepareOwnershipProofResponse:
      type: object
      description: |
        The fully-assembled message to sign verbatim. No preparation id, no expiry and no provider
        identifier are returned — the embedded provider customer token stays opaque inside `message`.
      properties:
        walletAddress:
          type: string
        blockchain:
          type: string
        message:
          type: string
          description: The message the client signs verbatim and blind with the destination wallet
        signedOnUtc:
          type: string
          description: The UTC date (ISO-8601) embedded in the message
          example: '2026-07-09'

    SinglePrepareOwnershipProofResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PrepareOwnershipProofResponse'

    SingleVirtualBankAccountResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/VirtualBankAccount'

    VirtualBankAccountListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/VirtualBankAccount'
            pagination:
              $ref: '#/components/schemas/Pagination'

    # ==================== PAYMENT SESSION SCHEMAS ====================

    PaymentSession:
      type: object
      properties:
        id:
          type: string
          format: uuid
        accountId:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/PaymentSessionStatus'
        inAmount:
          type: number
          description: Fiat amount to be paid in
        inCurrency:
          type: string
          description: Fiat currency of the incoming payment
        outCryptocurrency:
          type: string
          description: Cryptocurrency the incoming fiat is converted to
        paymentUrl:
          type: string
          format: uri
          description: Hosted URL to redirect the payer to
        externalRef:
          type: string
        walletId:
          type: string
          format: uuid
        blockchainTxId:
          type: string
          nullable: true
        cancellable:
          type: boolean
        expiresAt:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties:
            type: string
        idempotencyKey:
          type: string
          format: uuid
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreatePayInSessionRequest:
      type: object
      required: [inAmount, inCurrency, outCryptocurrency, callbackUrl, idempotencyKey]
      properties:
        inAmount:
          type: string
          pattern: "^\\d+(\\.\\d{1,2})?$"
          description: Fiat amount to pay in
        inCurrency:
          type: string
          minLength: 1
          description: Fiat currency of the incoming payment (e.g. EUR)
        outCryptocurrency:
          type: string
          minLength: 1
          description: Cryptocurrency to convert the incoming fiat to (e.g. USDC)
        callbackUrl:
          type: string
          pattern: "^https://.*"
          description: HTTPS URL to receive payment status callbacks
        successRedirectUrl:
          type: string
          description: URL to redirect the user to on successful payment
        failureRedirectUrl:
          type: string
          description: URL to redirect the user to on failed payment
        externalRef:
          type: string
          maxLength: 255
          description: Optional external reference
        idempotencyKey:
          type: string
          format: uuid
          description: Unique UUID key to prevent duplicate operations on retry
        metadata:
          type: object
          additionalProperties:
            type: string

    SinglePaymentSessionResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PaymentSession'






    CardProviderReference:
      type: object
      required: [type, referenceId]
      properties:
        type:
          type: string
          minLength: 1
          description: Card provider type identifier (case-sensitive)
        referenceId:
          type: string
          minLength: 1
          description: Unique reference ID from the card provider












    # ==================== TRANSFER SCHEMAS ====================

    FiatOrigin:
      type: object
      description: Fiat origin details for a fiat-denominated transfer
      properties:
        currency:
          type: string
        amount:
          type: number
        exchangeRate:
          type: number

    Transfer:
      type: object
      properties:
        id:
          type: string
          format: uuid
        senderAccountId:
          type: string
          format: uuid
        receiverAccountId:
          type: string
          format: uuid
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        asset:
          type: string
        amount:
          type: number
        fiatOrigin:
          $ref: '#/components/schemas/FiatOrigin'
        description:
          type: string
        merchantReference:
          type: string
        idempotencyKey:
          type: string
        status:
          $ref: '#/components/schemas/TransferStatus'
        transactionHash:
          type: string
        errorMessage:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateFiatTransferInput:
      type: object
      required: [currency, amount, idempotencyKey]
      properties:
        receiverAccountId:
          type: string
          format: uuid
          description: ID of the receiving account
        receiverExternalId:
          type: string
          description: External ID of the receiving account (alternative to receiverAccountId)
        currency:
          type: string
          minLength: 1
          description: Fiat currency code (e.g. EUR, USD)
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        asset:
          type: string
          description: |
            Crypto asset to settle in (e.g. `USDC`). Send **together with `chain`** to name exactly
            which asset a fiat amount settles in.

            Optional while `currency` maps to a single asset for your tenant. Required as soon as it
            maps to more than one — for example USDC on two chains both denominated in USD — in which
            case omitting the pair is rejected with `ambiguous-asset` rather than guessing. Read
            [GET /supported-assets](/api-reference/Finance-API/supported-assets/list-supported-assets)
            to see which applies to you.
          example: USDC
        amount:
          type: number
          description: Transfer amount
        description:
          type: string
          description: Optional transfer description
        merchantReference:
          type: string
          description: Optional merchant reference
        idempotencyKey:
          type: string
          minLength: 1
          description: Unique key to prevent duplicate operations on retry

    CreateCryptoTransferInput:
      type: object
      required: [chain, asset, amount, idempotencyKey]
      properties:
        receiverAccountId:
          type: string
          format: uuid
          description: ID of the receiving account
        receiverExternalId:
          type: string
          description: External ID of the receiving account (alternative to receiverAccountId)
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        asset:
          type: string
          minLength: 1
          description: Cryptocurrency asset identifier (e.g. USDC)
        amount:
          type: number
          description: Transfer amount
        description:
          type: string
          description: Optional transfer description
        merchantReference:
          type: string
          description: Optional merchant reference
        idempotencyKey:
          type: string
          minLength: 1
          description: Unique key to prevent duplicate operations on retry

    SingleTransferResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/Transfer'

    TransferListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/Transfer'
            pagination:
              $ref: '#/components/schemas/Pagination'

    # ==================== PERMIT SCHEMAS ====================

    Eip712TypeEntry:
      type: object
      properties:
        name:
          type: string
        type:
          type: string

    Eip712Domain:
      type: object
      properties:
        name:
          type: string
        version:
          type: string
        chainId:
          type: integer
          format: int32
        verifyingContract:
          type: string

    PermitData:
      type: object
      description: EIP-2612 permit message payload
      properties:
        owner:
          type: string
        spender:
          type: string
        value:
          type: string
        nonce:
          type: integer
          format: int64
        deadline:
          type: string

    Eip712PermitMessage:
      type: object
      description: EIP-712 typed-data structure to sign
      properties:
        types:
          type: object
          additionalProperties:
            type: array
            items:
              $ref: '#/components/schemas/Eip712TypeEntry'
        primaryType:
          type: string
        domain:
          $ref: '#/components/schemas/Eip712Domain'
        message:
          $ref: '#/components/schemas/PermitData'

    PermitMessage:
      type: object
      properties:
        supportedAssetId:
          type: string
          format: uuid
        asset:
          type: string
        contractAddress:
          type: string
        status:
          $ref: '#/components/schemas/PermitStatus'
        typedData:
          $ref: '#/components/schemas/Eip712PermitMessage'

    PermitSignature:
      type: object
      required: [v, r, s]
      properties:
        v:
          type: string
          description: Recovery byte of the signature
        r:
          type: string
          pattern: "^0x[0-9a-fA-F]{64}$"
          description: First 32 bytes of the signature
        s:
          type: string
          pattern: "^0x[0-9a-fA-F]{64}$"
          description: Second 32 bytes of the signature

    SubmitPermitRequest:
      type: object
      required: [supportedAssetId, signature]
      properties:
        supportedAssetId:
          type: string
          format: uuid
          description: ID of the supported asset to grant spending permission for
        signature:
          $ref: '#/components/schemas/PermitSignature'

    PermitResult:
      type: object
      properties:
        id:
          type: string
          format: uuid
        supportedAssetId:
          type: string
          format: uuid
        asset:
          type: string
        contractAddress:
          type: string
        status:
          $ref: '#/components/schemas/PermitStatus'
        permitTxId:
          type: string
          description: Internal permit transaction identifier (UUID, not an on-chain hash)
        skipped:
          type: boolean
          description: True if the permit was a no-op (e.g. allowance already granted)

    PermitMessageListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/PermitMessage'

    SinglePermitResultResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PermitResult'

    # ==================== ALLOWANCE SCHEMAS ====================

    SettlementAsset:
      type: object
      properties:
        name:
          type: string
          description: Asset symbol (e.g. USDC)
        contractAddress:
          type: string
        decimals:
          type: integer
          format: int32

    Allowance:
      type: object
      properties:
        asset:
          $ref: '#/components/schemas/SettlementAsset'
        allowance:
          type: string
          description: Approved amount as a decimal string (uint256-max indicates unlimited)
        orchestrationWallet:
          type: string
          description: Address of the orchestration wallet approved as spender

    AllowanceListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/Allowance'
    # ==================== PARTNER-TERMS CONSENT SCHEMAS ====================

    PartnerTermsStatus:
      type: string
      description: |
        Whether a party's end customer still has to accept our partners' terms. Party-level and
        partner-agnostic — one value regardless of how many partners are involved.

        - `NOT_REQUIRED` — nothing outstanding. Also the answer for a party that has not passed KYC yet, since
          there is nothing to accept before then.
        - `REQUIRED` — the end customer must accept before onboarding can proceed. A `consentUrl` is always
          present alongside this value.
        - `ACCEPTED` — every enabled partner's **currently-active** terms have been accepted, so onboarding
          is not blocked on consent.

        <Note>
        `ACCEPTED` is **not** terminal, because it is evaluated against the terms that are active *now*.
        Enabling a new partner, or a partner publishing a new terms version, supersedes what was accepted and
        moves the party back to `REQUIRED`. Re-read it rather than caching it as a completed milestone.
        </Note>
      enum: [NOT_REQUIRED, REQUIRED, ACCEPTED]

    PartyPartnerTerms:
      type: object
      description: A party's partner-terms consent state, current as of the call.
      required: [partyId, status]
      properties:
        partyId:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/PartnerTermsStatus'
        consentUrl:
          type: string
          nullable: true
          description: |
            The hosted page to hand to the end customer. Present **only** while `status` is `REQUIRED` — that
            pairing is guaranteed, so a `REQUIRED` without a URL is reported as a retryable `503` rather than
            returned to you.

            Treat it as a credential: it embeds a scoped, expiring token and is specific to this party.
          example: 'https://onboard.venly.io/partner-terms?token=cons-tok-9f3a2b'

    SinglePartyPartnerTermsResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PartyPartnerTerms'

    PartyPartnerTermsLink:
      type: object
      description: |
        A freshly issued consent link, letting the end customer accept every enabled partner's latest terms up
        front — before any of their data reaches a partner.
      required: [partyId, consentUrl]
      properties:
        partyId:
          type: string
          format: uuid
        consentUrl:
          type: string
          description: The hosted page to hand to the end customer. Treat it as a credential.
          example: 'https://onboard.venly.io/partner-terms?token=cons-tok-4c81de'
        expiresAt:
          type: string
          format: date-time
          nullable: true
          description: When this link stops working. Issue a new one rather than trying to extend it.

    SinglePartyPartnerTermsLinkResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PartyPartnerTermsLink'

    # ==================== SUPPORTED ASSET SCHEMAS ====================

    SupportedAsset:
      type: object
      description: A chain/asset pair your tenant is enabled to settle in.
      properties:
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        cryptoCurrency:
          type: string
          description: Asset symbol
          example: USDC
        decimals:
          type: integer
          description: On-chain decimals for the asset, read from its contract
          example: 6
        contractAddress:
          type: string
          description: Token contract address on `chain`
          example: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'

    AccountSupportedAsset:
      allOf:
        - $ref: '#/components/schemas/SupportedAsset'
        - type: object
          properties:
            permitStatus:
              $ref: '#/components/schemas/AssetPermitStatus'

    AssetPermitStatus:
      type: string
      description: |
        Whether a specific account can move a specific asset right now. Derived per request from the
        account's wallets on that asset's chain — never stored, so re-read it rather than caching.

        - `READY` — usable now. The allowance is in place and the account's wallets are active.
        - `ACTIVATING` — the allowance is in place but the wallets are not usable yet. No action from
          you; re-read shortly. A stuck `ACTIVATING` is one to raise with support.
        - `ACTION_REQUIRED` — **the customer must act.** Self-custody wallets only: no allowance
          exists yet, and only the wallet's owner can grant it. Send them through the
          [permit or allowance flow](/guides/finance/permits-and-allowances).
        - `PENDING` — no allowance yet on a Venly-managed wallet. Venly handles it; no action from you.
        - `FAILED` — granting the allowance failed. Retry through the permit flow, or contact support.
        - `NO_WALLET` — this account has no wallet on that asset's chain, so the asset is unusable
          regardless of allowances.
      enum: [READY, ACTIVATING, ACTION_REQUIRED, PENDING, FAILED, NO_WALLET]

    SupportedAssetListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/SupportedAsset'

    AccountSupportedAssetListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/AccountSupportedAsset'

    # ==================== ALLOWANCE VERIFICATION SCHEMAS ====================

    WalletStatus:
      type: string
      description: |
        Lifecycle of an account or escrow wallet. Only `ACTIVE` wallets can move funds.

        - `PENDING` — created; no usable allowance yet
        - `ACTIVE` — usable
        - `VERIFYING_ALLOWANCE` — on-chain allowances are being verified so the wallet can activate
          without a permit. A waiting room, **not** a usable state: every check that requires an
          active wallet treats it exactly like `PENDING`. Reached only by self-custody account
          wallets, and only via
          [Activate a wallet from its on-chain allowances](/api-reference/Finance-API/allowances/verify-wallet-allowances).
        - `FROZEN` — administratively blocked
        - `CLOSED` — no longer in use
      enum: [PENDING, ACTIVE, VERIFYING_ALLOWANCE, FROZEN, CLOSED]

    AllowanceVerificationResult:
      type: object
      properties:
        walletId:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/WalletStatus'
        verificationExpiresAt:
          type: string
          format: date-time
          nullable: true
          description: |
            When the verification window closes. Present while `status` is `VERIFYING_ALLOWANCE`.
            Grant the missing allowances before this passes, or the wallet reverts to `PENDING` and
            you have to start again. Null once the wallet is `ACTIVE`.
        assets:
          type: array
          description: Per-asset outcome, so you can see exactly which allowances are still missing
          items:
            $ref: '#/components/schemas/AllowanceVerificationAsset'

    AllowanceVerificationAsset:
      type: object
      properties:
        supportedAssetId:
          type: string
          format: uuid
        asset:
          type: string
          example: USDC
        contractAddress:
          type: string
        allowanceSufficient:
          type: boolean
          description: Whether the on-chain allowance for this asset covers what settlement needs
        permitStatus:
          $ref: '#/components/schemas/PermitStatus'

    SingleAllowanceVerificationResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/AllowanceVerificationResult'

    # ==================== PAYOUT ROUTE OWNERSHIP PROOF SCHEMAS ====================

    PayoutOwnershipProof:
      type: object
      description: The message a self-custody wallet owner must sign to prove they control the route's source wallet.
      properties:
        walletAddress:
          type: string
          description: The wallet that must sign
        blockchain:
          $ref: '#/components/schemas/BlockchainNetwork'
        message:
          type: string
          description: |
            Sign this **verbatim**. Do not reformat, re-encode, pretty-print or reconstruct it — the
            signature is checked against these exact bytes.
        signedOnUtc:
          type: string
          format: date
          description: |
            UTC date embedded in the message. The message is date-bound, so a proof prepared on one
            day cannot be submitted against a message built for another.

    CompletePayoutOwnershipProofRequest:
      type: object
      required: [message, signature]
      properties:
        message:
          type: string
          description: The message exactly as `prepare` returned it
        signature:
          type: string
          description: EIP-191 signature produced by the route's source wallet

    SinglePayoutOwnershipProofResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PayoutOwnershipProof'

    PartyListItem:
      type: object
      description: Slim party representation returned in list responses. A single-party fetch returns more fields (address, kycStatus/kybStatus, updatedAt, version).
      properties:
        id:
          type: string
          format: uuid
        externalId:
          type: string
        partyType:
          $ref: '#/components/schemas/PartyType'
        status:
          $ref: '#/components/schemas/PartyStatus'
        firstName:
          type: string
          description: Individual parties only
        lastName:
          type: string
          description: Individual parties only
        name:
          type: string
          description: Organisation parties only
        createdAt:
          type: string
          format: date-time

    # ==================== IDENTITY VERIFICATION SCHEMAS ====================

    PartyVerificationLink:
      type: object
      description: |
        A hosted verification link minted for a party. Only the URL matching the party's type is
        exposed — the KYC flow for `INDIVIDUAL`, the KYB flow for `ORGANISATION`. The link does not
        expire; it stays valid until revoked, and a re-POST reissues the
        same URL.
      required: [partyId, verificationUrl]
      properties:
        partyId:
          type: string
          format: uuid
        verificationUrl:
          type: string
          format: uri
          description: |
            The hosted onboarding URL for the party's flow. Hand this to the end-user and **treat it as
            a credential** — it grants access to their verification session.
          example: https://onboard.venly.io/kyc?token=inv-tok-9f2c41ab
        status:
          type: string
          nullable: true
          description: |
            The party's verification status at mint time — `VERIFICATION_PENDING`/`REJECTED` for an
            `INDIVIDUAL`, `PENDING`/`DENIED` for an `ORGANISATION`. Never `VERIFIED`; an already-verified
            party returns `409 party-already-verified` instead.
          example: VERIFICATION_PENDING

    SinglePartyVerificationLinkResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PartyVerificationLink'

    IvLinkageStatus:
      type: string
      description: |
        A party's verification linkage status. This tracks the
        *plumbing*, not the verdict — read the party's `kycStatus`/`kybStatus` for the outcome.
        - `NOT_LINKED` — no verification has been started for this party
        - `SUBMITTED` — a verification request was submitted
        - `FORWARDED` — forwarded for verification
        - `ACCEPTED` — the platform accepted the case
        - `COMPLETED` — a verdict was received and applied to the party
        - `FAILED` — the linkage failed, or the verdict was a decline
      enum: [NOT_LINKED, SUBMITTED, FORWARDED, ACCEPTED, COMPLETED, FAILED]

    PartyIvVerification:
      type: object
      description: |
        A party's identity-verification linkage: the current linkage status plus the durable case
        reference. `ivCaseReference` is populated once the platform has returned a reference and is
        never cleared afterwards — including on `FAILED` after a declined verdict. Read it directly
        rather than inferring it from `status`.
      required: [partyId, status]
      properties:
        partyId:
          type: string
          format: uuid
        ivCaseReference:
          type: string
          nullable: true
          description: |
            Durable reference to the party's verification case, quotable to support. Null only while no
            reference was ever returned.
          example: iv-case-8842f19c
        status:
          $ref: '#/components/schemas/IvLinkageStatus'
        linkedAt:
          type: string
          format: date-time
          nullable: true
          description: When the case reference was first populated; null until the party is linked.

    SinglePartyIvVerificationResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PartyIvVerification'

    # ==================== PAYOUT SCHEMAS ====================

    PayoutRail:
      type: string
      description: |
        The fiat rail a payout bank account settles over.
        - `US_ACH` — US ACH transfer. Requires `fiatCurrency: USD`.
      enum: [US_ACH]

    PayoutBankAccountStatus:
      type: string
      description: |
        Lifecycle status of a payout bank account.
        - `PENDING` — registered, not yet activated for pay-outs
        - `ACTIVE` — usable as a pay-out destination
        - `DISABLED` — no longer usable
      enum: [PENDING, ACTIVE, DISABLED]

    BankAddress:
      type: object
      description: |
        Structured postal address of the **beneficiary's bank** — not the recipient's own address. All
        components are required on registration, and `country` must be a valid ISO 3166-1 alpha-2
        code; anything missing or invalid yields `400 invalid-bank-address`.
      required: [street1, city, region, postalCode, country]
      properties:
        street1:
          type: string
          maxLength: 255
        city:
          type: string
          maxLength: 100
        region:
          type: string
          maxLength: 100
          description: State, province or region
        postalCode:
          type: string
          maxLength: 20
        country:
          type: string
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code
          example: US

    UsAchRailDetailsInput:
      type: object
      description: Raw US-ACH rail credentials. Accepted only when registering a payout bank account.
      required: [accountNumber, abaRoutingNumber, accountType]
      properties:
        accountNumber:
          type: string
          writeOnly: true
          description: |
            Bank account number, 4–17 digits. KMS-encrypted at rest immediately on persistence and
            **never returned** — read paths expose only `accountNumberLast4`.
        abaRoutingNumber:
          type: string
          description: 9-digit ABA routing number, validated by checksum. A public bank identifier, returned in full.
          example: '021000021'
        accountType:
          $ref: '#/components/schemas/AchAccountType'

    MaskedRailDetails:
      type: object
      description: Masked rail credentials, returned on every read path.
      properties:
        accountNumberLast4:
          type: string
          description: Last 4 digits of the account number. The full number is never returned.
          example: '9012'
        abaRoutingNumber:
          type: string
          description: ABA routing number, returned in full (a public bank identifier).
          example: '021000021'
        accountType:
          $ref: '#/components/schemas/AchAccountType'

    RegisterPayoutBankAccountRequest:
      type: object
      description: |
        Allow-lists a beneficiary bank account as a pay-out destination. The owning party comes from
        the path, never the body.
      required: [rail, fiatCurrency, label, accountHolderName, railDetails, bankName, bankAddress]
      properties:
        rail:
          $ref: '#/components/schemas/PayoutRail'
        fiatCurrency:
          type: string
          maxLength: 3
          description: ISO 4217 currency. Must be consistent with the rail — `US_ACH` requires `USD`.
          example: USD
        label:
          type: string
          maxLength: 255
          description: Human-readable label for this destination
        accountHolderName:
          type: string
          maxLength: 255
          description: Name on the beneficiary bank account
        railDetails:
          $ref: '#/components/schemas/UsAchRailDetailsInput'
        bankName:
          type: string
          maxLength: 255
        bankAddress:
          $ref: '#/components/schemas/BankAddress'
        beneficiaryEmail:
          type: string
          format: email
          maxLength: 255
          description: |
            Beneficiary contact email, forwarded to the payment provider as part of the Travel-Rule
            record. Optional for providers that do not require it, but some destinations are refused
            without both a beneficiary email and phone number, so supply both on every bank account
            you register.

            When the email is omitted the beneficiary party's email is used as a fallback; the phone
            number has no such fallback and must be supplied on the bank account itself. A bank
            account cannot be edited after registration, so if the resolved email or the phone is
            missing, the route fails terminally with `beneficiary-data-incomplete` and the
            beneficiary has to be registered again.
          example: finance@acme.com
        beneficiaryPhoneNumber:
          type: string
          maxLength: 16
          pattern: '^\+[1-9][0-9]{7,14}$'
          description: |
            Beneficiary contact phone in E.164 form (leading `+`, no spaces or punctuation). Same
            applicability as `beneficiaryEmail`. Forwarded to the payment provider verbatim and not
            normalised, so a non-E.164 number is rejected here rather than failing the route
            registration later.
          example: '+12125550123'

    PayoutBankAccount:
      type: object
      description: Masked view of an allow-listed payout bank account.
      properties:
        id:
          type: string
          format: uuid
        partyId:
          type: string
          format: uuid
        rail:
          $ref: '#/components/schemas/PayoutRail'
        fiatCurrency:
          type: string
        label:
          type: string
        accountHolderName:
          type: string
        details:
          $ref: '#/components/schemas/MaskedRailDetails'
        bankName:
          type: string
        bankAddress:
          $ref: '#/components/schemas/BankAddress'
        status:
          $ref: '#/components/schemas/PayoutBankAccountStatus'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    SinglePayoutBankAccountResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PayoutBankAccount'

    PayoutBankAccountListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/PayoutBankAccount'
            pagination:
              $ref: '#/components/schemas/Pagination'

    DepositAsset:
      type: object
      description: The crypto asset a customer sends to fund a route — a (chain, name) pair.
      required: [chain, name]
      properties:
        chain:
          $ref: '#/components/schemas/BlockchainNetwork'
        name:
          type: string
          maxLength: 50
          description: Crypto asset name
          example: USDC

    PayoutRouteStatus:
      type: string
      description: |
        Lifecycle of a payout route. Only `ACTIVE` routes can be used for pay-outs or as PUSH deposit
        addresses.
        - `PENDING` — created; provider registration not yet started
        - `REGISTERING` — provider registration in progress
        - `AWAITING_OWNERSHIP_PROOF` — waiting for the owner of a self-custody source wallet to sign
          a wallet-ownership proof. Registration is paused until you submit it — see
          [Submit a pay-out route ownership proof](/api-reference/Finance-API/payout-routes/submit-a-route-ownership-proof).
          This is the only status that needs an action from you.
        - `ACTIVE` — registered; `depositAddress` populated
        - `REJECTED` — registration failed terminally or was rejected
      enum: [PENDING, REGISTERING, AWAITING_OWNERSHIP_PROOF, ACTIVE, REJECTED]

    CreatePayoutRouteRequest:
      type: object
      description: |
        Creates a pay-out route. The owning account comes from the path, and the provider is resolved
        internally — the request never names one.
      required: [payoutBankAccountId, depositAsset]
      properties:
        payoutBankAccountId:
          type: string
          format: uuid
          description: An allow-listed payout bank account belonging to your company
        depositAsset:
          $ref: '#/components/schemas/DepositAsset'

    PayoutRoute:
      type: object
      description: Customer-facing route view. Provider identity is never exposed.
      properties:
        id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/PayoutRouteStatus'
        depositAsset:
          $ref: '#/components/schemas/DepositAsset'
        fiatCurrency:
          type: string
        depositAddress:
          type: string
          nullable: true
          description: |
            The address a customer sends to for a PUSH pay-out. Included **only** for an `ACTIVE` route
            on a `SELF_CUSTODY` account; null or omitted otherwise.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    SinglePayoutRouteResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/PayoutRoute'

    PayoutRouteListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/PayoutRoute'

    PayoutRouteSummary:
      type: object
      description: |
        Compact route reference inside a pay-out list item — route-only, with no beneficiary and no
        deposit address.
      properties:
        id:
          type: string
          format: uuid
        depositAsset:
          $ref: '#/components/schemas/DepositAsset'
        fiatCurrency:
          type: string
          example: USD
        status:
          $ref: '#/components/schemas/PayoutRouteStatus'

    PayoutBeneficiary:
      type: object
      description: |
        Masked beneficiary bank account nested under a pay-out's route. The raw account number is
        never returned.
      properties:
        id:
          type: string
          format: uuid
        partyId:
          type: string
          format: uuid
          description: The owning payee party
        rail:
          $ref: '#/components/schemas/PayoutRail'
        label:
          type: string
        accountHolderName:
          type: string
        bankName:
          type: string
        details:
          $ref: '#/components/schemas/MaskedRailDetails'

    PayoutRouteInfo:
      type: object
      description: The route lane nested in the full pay-out shape, including the masked beneficiary.
      properties:
        id:
          type: string
          format: uuid
        depositAsset:
          $ref: '#/components/schemas/DepositAsset'
        fiatCurrency:
          type: string
          example: USD
        depositAddress:
          type: string
          nullable: true
          description: Included only for an `ACTIVE` route on a `SELF_CUSTODY` account.
        beneficiary:
          $ref: '#/components/schemas/PayoutBeneficiary'

    PayoutStatus:
      type: string
      description: |
        Pay-out lifecycle. The PULL happy path is
        `REQUESTED → SENDING → PROVIDER_PROCESSING → COMPLETED`; PUSH pay-outs start at
        `PROVIDER_PROCESSING`. Note that a fiat leg can still bounce after completion
        (`COMPLETED → RETURNED`).
        - `REQUESTED` — created via the API; awaiting dispatch of the managed send leg. PULL only
        - `SENDING` — the managed on-chain send is in flight. PULL only
        - `PROVIDER_PROCESSING` — funds reached the provider deposit address; awaiting the fiat pay-out
        - `COMPLETED` — the provider reported the fiat pay-out as completed; `settledFiatAmount` is set
        - `REJECTED` — request validation failed before dispatch. PULL only. Terminal
        - `FAILED` — the managed send leg failed terminally. PULL only. Terminal
        - `RETURNED` — the provider reported the fiat leg as bounced or returned. Terminal
      enum: [REQUESTED, SENDING, PROVIDER_PROCESSING, COMPLETED, REJECTED, FAILED, RETURNED]

    FundingMode:
      type: string
      description: |
        How a pay-out is funded on-chain.
        - `PULL` — Venly sends from the account wallet using the up-front permit. Created via the API
        - `PUSH` — the customer already sent to the route's deposit address themselves. Created from the
          observed deposit; self-custody only
      enum: [PULL, PUSH]

    CreatePayoutRequest:
      type: object
      description: |
        Requests a PULL pay-out. The route fixes the beneficiary, lane, asset and fiat currency, so
        there is no fiat target, no provider field and no funding-mode field.
      required: [payoutRouteId, cryptoAmount, idempotencyKey]
      properties:
        payoutRouteId:
          type: string
          format: uuid
          description: The selected payout route. Must belong to this account and be `ACTIVE`.
        cryptoAmount:
          type: number
          description: |
            Crypto amount to send to the provider's deposit address. Must be greater than zero, with at
            most 20 integer digits and 18 fractional digits.
          example: 100.00
        idempotencyKey:
          type: string
          maxLength: 255
          description: |
            Client-supplied idempotency key. Must be unique per company across all idempotent
            endpoints.
          example: payout-2026-07-14-0001

    Payout:
      type: object
      description: |
        Full pay-out shape, returned on creation and by the detail read. Never carries the idempotency
        key, permit material, internal wallet ids, managed transaction ids or raw provider data.
      properties:
        id:
          type: string
          format: uuid
        accountId:
          type: string
          format: uuid
        payoutRoute:
          $ref: '#/components/schemas/PayoutRouteInfo'
        rail:
          allOf:
            - $ref: '#/components/schemas/PayoutRail'
          nullable: true
          description: The fiat rail this pay-out settles over. Null until the route is resolved.
        cryptoAmount:
          type: number
          description: Crypto amount sent (PULL) or observed (PUSH)
          example: 100.00
        settledFiatAmount:
          type: number
          nullable: true
          description: Fiat amount actually paid out, as reported by the provider. Null until `COMPLETED`.
          example: 99.50
        fundingMode:
          $ref: '#/components/schemas/FundingMode'
        status:
          $ref: '#/components/schemas/PayoutStatus'
        sendTxHash:
          type: string
          nullable: true
          description: |
            On-chain send transaction hash — the managed broadcast for PULL, the observed customer
            transaction for PUSH. Null until known.
        requestedAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
        failureReason:
          type: string
          nullable: true
          description: Populated for `REJECTED`, `FAILED` and `RETURNED`.

    PayoutSummary:
      type: object
      description: |
        Compact pay-out list item — the same top-level fields as `Payout`, but with a route-only
        `payoutRoute`.
      properties:
        id:
          type: string
          format: uuid
        accountId:
          type: string
          format: uuid
        payoutRoute:
          $ref: '#/components/schemas/PayoutRouteSummary'
        rail:
          allOf:
            - $ref: '#/components/schemas/PayoutRail'
          nullable: true
        cryptoAmount:
          type: number
          example: 100.00
        settledFiatAmount:
          type: number
          nullable: true
          example: 99.50
        fundingMode:
          $ref: '#/components/schemas/FundingMode'
        status:
          $ref: '#/components/schemas/PayoutStatus'
        sendTxHash:
          type: string
          nullable: true
        requestedAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
        failureReason:
          type: string
          nullable: true

    SinglePayoutResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/Payout'

    PayoutListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/PayoutSummary'
            pagination:
              $ref: '#/components/schemas/Pagination'

    # ==================== WEBHOOK SCHEMAS ====================

    ApiKeyAuthenticationMethod:
      type: object
      description: API-key authentication — the key is injected into the request header you name.
      required: [type, headerName, apiKey]
      properties:
        type:
          type: string
          enum: [API_KEY]
        headerName:
          type: string
          maxLength: 255
          description: The request header the API key is sent in
          example: X-Webhook-Key
        apiKey:
          type: string
          maxLength: 2048
          writeOnly: true
          description: Write-only secret. Never returned on read responses.

    BasicAuthenticationMethod:
      type: object
      description: HTTP basic authentication.
      required: [type, username, password]
      properties:
        type:
          type: string
          enum: [BASIC_AUTHENTICATION]
        username:
          type: string
          maxLength: 255
        password:
          type: string
          maxLength: 255
          writeOnly: true
          description: Write-only secret. Never returned on read responses.

    WebhookAuthenticationMethod:
      description: |
        Authentication applied to outbound webhook deliveries, discriminated by `type`. Secret fields
        (`apiKey`, `password`) are accepted on input and omitted from every read response.
      oneOf:
        - $ref: '#/components/schemas/ApiKeyAuthenticationMethod'
        - $ref: '#/components/schemas/BasicAuthenticationMethod'
      discriminator:
        propertyName: type
        mapping:
          API_KEY: '#/components/schemas/ApiKeyAuthenticationMethod'
          BASIC_AUTHENTICATION: '#/components/schemas/BasicAuthenticationMethod'

    CreateWebhookRequest:
      type: object
      description: Registers a webhook endpoint for your company.
      required: [url, authenticationMethod]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: Absolute `https://` endpoint the webhook delivers to
          example: https://client.example/hooks/venly
        name:
          type: string
          maxLength: 255
          nullable: true
          description: Optional human-readable label
        authenticationMethod:
          $ref: '#/components/schemas/WebhookAuthenticationMethod'

    UpdateWebhookRequest:
      type: object
      description: |
        Replaces an existing webhook's configuration. Same shape and validation as registration — a
        full replacement, not a partial patch.
      required: [url, authenticationMethod]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: Absolute `https://` endpoint the webhook delivers to
        name:
          type: string
          maxLength: 255
          nullable: true
        authenticationMethod:
          $ref: '#/components/schemas/WebhookAuthenticationMethod'

    Webhook:
      type: object
      description: A registered webhook. Authentication secrets are omitted from this representation.
      required: [id, url, status]
      properties:
        id:
          type: string
          example: wh_8842f19c
        url:
          type: string
          format: uri
        name:
          type: string
          nullable: true
        authenticationMethod:
          $ref: '#/components/schemas/WebhookAuthenticationMethod'
        status:
          type: string
          enum: [ACTIVE]

    SingleWebhookResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              $ref: '#/components/schemas/Webhook'

    WebhookListResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
        - type: object
          properties:
            result:
              type: array
              items:
                $ref: '#/components/schemas/Webhook'
