openapi: 3.0.3
info:
  title: WasaaPay API
  description: >
    WasaaPay is a multi-currency payment aggregator for platforms and marketplaces
    operating in Africa. This specification covers the public merchant-facing API:
    collections, sub-merchant onboarding, wallets, split rules, payouts, webhooks,
    and FX. It implements the conventions defined in the WasaaPay Backend
    Engineering Document (response envelope, idempotency, error catalog) and the
    schemas defined in the WasaaPay Database Design Document.
  version: "1.0.0"
  contact:
    name: WasaaPay Developer Platform
    url: https://developers.wasaapay.com
    email: developers@wasaapay.com
  license:
    name: Proprietary
    url: https://wasaapay.com/legal

servers:
  - url: https://api.wasaapay.com/v1
    description: Production
  - url: https://sandbox.api.wasaapay.com/v1
    description: Sandbox

tags:
  - name: Collections
    description: Initiate and inspect payment collections across mobile money, card, and bank rails.
  - name: Checkout
    description: Hosted-checkout sessions — payer-facing, capability-token flows where the payer picks the rail.
  - name: Transactions
    description: Search and inspect transaction records.
  - name: Sub-Merchants
    description: Onboard and manage sellers, vendors, riders, and individual earners.
  - name: Team
    description: Dashboard team management — the users belonging to a merchant.
  - name: Wallets
    description: Multi-currency wallet balances.
  - name: Split Rules
    description: Configure how a transaction's value is divided among recipients.
  - name: Payouts
    description: Single and bulk disbursements.
  - name: Webhooks
    description: Register endpoints to receive event notifications.
  - name: FX
    description: Foreign exchange rates and rate locks.

security:
  - apiKeyAuth: []

paths:
  # ---------------------------------------------------------------------
  # Collections
  # ---------------------------------------------------------------------
  /collections:
    post:
      operationId: createCollection
      tags: [Collections]
      summary: Initiate a payment collection
      description: >
        Creates a new collection (payment) via mobile money, card, or bank rail.
        Requires an Idempotency-Key header; retrying the same key returns the
        original result rather than creating a duplicate transaction.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CollectionCreateRequest'
            example:
              amount: 150000
              currency: KES
              rail: mobile_money
              payer_phone: "2547XXXXXXXX"
              reference: order_10293
      responses:
        '200':
          description: Collection created (or the original result, if the idempotency key was already used).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientFunds'
        '403':
          $ref: '#/components/responses/ComplianceHold'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/RailUnavailable'

  /collections/{id}:
    get:
      operationId: getCollection
      tags: [Collections]
      summary: Retrieve collection status
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The collection's current status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /collections/{id}/refunds:
    post:
      operationId: createRefund
      tags: [Collections]
      summary: Refund a collection (partial or full)
      description: >
        Creates a refund transaction against a successful collection. Omit
        amount (or the request body) to refund the full remaining amount;
        supply amount in minor currency units for a partial refund. The refund
        is returned as a transaction with type=refund and
        parent_transaction_id set to the original collection's ID. Requires an
        Idempotency-Key header; retrying the same key returns the original
        result rather than creating a duplicate refund.
      parameters:
        - $ref: '#/components/parameters/ResourceId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefundCreateRequest'
            example:
              amount: 50000
              reason: Customer returned one of two items.
      responses:
        '200':
          description: Refund created (or the original result, if the idempotency key was already used).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientFunds'
        '403':
          $ref: '#/components/responses/ComplianceHold'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/RailUnavailable'

  # ---------------------------------------------------------------------
  # Hosted Checkout Sessions
  # ---------------------------------------------------------------------
  /checkout-sessions:
    post:
      operationId: createCheckoutSession
      tags: [Checkout]
      summary: Open a hosted-checkout session
      description: >
        Creates a payer-facing checkout session for a fixed amount/currency and
        returns a one-time client_token capability secret. Unlike
        createCollection (where the merchant picks the rail at creation), the
        PAYER later chooses a rail from allowed_rails and pays via
        payCheckoutSession — authorized ONLY by the client_token, never the
        merchant API key. Requires an Idempotency-Key header; retrying the same
        key returns the original session (and its token) rather than minting a
        duplicate.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckoutSessionCreateRequest'
            example:
              amount: 150000
              currency: KES
              reference: order_10293
              allowed_rails: [mobile_money, card]
      responses:
        '200':
          description: Session created (or the original result, if the idempotency key was already used).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSessionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /checkout-sessions/{id}:
    get:
      operationId: getCheckoutSession
      tags: [Checkout]
      summary: Retrieve a checkout session (payer-facing)
      description: >
        Returns the non-sensitive fields the hosted checkout renders. Authorized
        by the session's client_token (the X-Checkout-Token header or a
        client_token query parameter), NOT the merchant API key; the client_token
        is never echoed back. A session past its expires_at is reported as
        expired.
      security:
        - checkoutTokenAuth: []
      parameters:
        - $ref: '#/components/parameters/ResourceId'
        - $ref: '#/components/parameters/CheckoutToken'
      responses:
        '200':
          description: The checkout session's payer-facing view.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSessionPublicEnvelope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /checkout-sessions/{id}/pay:
    post:
      operationId: payCheckoutSession
      tags: [Checkout]
      summary: Pay a checkout session (payer-facing)
      description: >
        The payer picks a rail from the session's allowed_rails and supplies the
        matching payer reference; this creates the underlying collection through
        the same path as createCollection and marks the session completed.
        Authorized by the session's client_token (the X-Checkout-Token header or
        a client_token query parameter). Requires an Idempotency-Key header; a
        same-key replay returns the original result, and a completed or expired
        session rejects new pays. Returns the resulting transaction (action_url /
        ussd_code are populated when a payer challenge is required).
      security:
        - checkoutTokenAuth: []
      parameters:
        - $ref: '#/components/parameters/ResourceId'
        - $ref: '#/components/parameters/CheckoutToken'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckoutSessionPayRequest'
            example:
              rail: mobile_money
              payer_phone: "2547XXXXXXXX"
      responses:
        '200':
          description: The collection created for this session (or the original result on an idempotent replay).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionEnvelope'
        '402':
          $ref: '#/components/responses/InsufficientFunds'
        '403':
          $ref: '#/components/responses/ComplianceHold'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/RailUnavailable'

  # ---------------------------------------------------------------------
  # Transactions
  # ---------------------------------------------------------------------
  /transactions:
    get:
      operationId: listTransactions
      tags: [Transactions]
      summary: List transactions
      description: Cursor-paginated list of transactions, filterable by status, rail, and date range.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            $ref: '#/components/schemas/TransactionStatus'
        - in: query
          name: rail
          schema:
            $ref: '#/components/schemas/Rail'
        - in: query
          name: date_from
          schema:
            type: string
            format: date-time
        - in: query
          name: date_to
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: A page of transactions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /transactions/{id}:
    get:
      operationId: getTransaction
      tags: [Transactions]
      summary: Retrieve a transaction by ID
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The transaction.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # Sub-Merchants
  # ---------------------------------------------------------------------
  /sub-merchants:
    post:
      operationId: createSubMerchant
      tags: [Sub-Merchants]
      summary: Onboard a new sub-merchant
      description: Programmatically onboard a seller, vendor, rider, or individual earner. Triggers a KYC case per the assigned kyc_tier.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubMerchantCreateRequest'
            example:
              display_name: Jane's Boutique
              type: individual
              country: KE
              kyc_tier: tier_1
              contact:
                phone: "2547XXXXXXXX"
                email: jane@example.com
      responses:
        '200':
          description: Sub-merchant created; KYC status will be pending until verification completes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubMerchantEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listSubMerchants
      tags: [Sub-Merchants]
      summary: List sub-merchants
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: kyc_status
          schema:
            $ref: '#/components/schemas/KycStatus'
      responses:
        '200':
          description: A page of sub-merchants.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubMerchantListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /sub-merchants/{id}:
    get:
      operationId: getSubMerchant
      tags: [Sub-Merchants]
      summary: Retrieve a sub-merchant by ID
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The sub-merchant.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubMerchantEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # Team
  # ---------------------------------------------------------------------
  /users:
    get:
      operationId: listUsers
      tags: [Team]
      summary: List the merchant's dashboard users (team roster)
      description: >
        Returns every dashboard user under the authenticated merchant. The
        listing is always scoped to the caller's merchant, so one merchant can
        never enumerate another merchant's users. Requires the team:read
        permission (admin, finance, read_only, or compliance roles).
      responses:
        '200':
          description: The merchant's team roster.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # Wallets
  # ---------------------------------------------------------------------
  /wallets:
    get:
      operationId: listWallets
      tags: [Wallets]
      summary: List wallets
      description: Returns every wallet owned by the authenticated merchant (one per currency) or its sub-merchants.
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: owner_id
          schema:
            type: string
            format: uuid
          description: Filter to a specific merchant or sub-merchant owner.
      responses:
        '200':
          description: A page of wallets.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /wallets/{id}/balance:
    get:
      operationId: getWalletBalance
      tags: [Wallets]
      summary: Retrieve current available and pending balance
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The wallet's current balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # Split Rules
  # ---------------------------------------------------------------------
  /split-rules:
    post:
      operationId: createSplitRule
      tags: [Split Rules]
      summary: Create a split rule
      description: Defines how a transaction's value is divided among recipients, with optional escrow per recipient.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SplitRuleCreateRequest'
            example:
              name: Standard marketplace split
              rule_type: percentage
              recipients:
                - recipient_type: platform
                  value: 10
                  escrow: false
                - recipient_type: sub_merchant
                  value: 90
                  escrow: true
                  escrow_release_condition: delivery_confirmation
      responses:
        '200':
          description: Split rule created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SplitRuleEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listSplitRules
      tags: [Split Rules]
      summary: List split rules
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: active
          schema:
            type: boolean
      responses:
        '200':
          description: A page of split rules.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SplitRuleListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /split-rules/{id}:
    get:
      operationId: getSplitRule
      tags: [Split Rules]
      summary: Retrieve a split rule by ID
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The split rule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SplitRuleEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # Payouts
  # ---------------------------------------------------------------------
  /payouts:
    post:
      operationId: createPayout
      tags: [Payouts]
      summary: Initiate a single payout
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayoutCreateRequest'
            example:
              wallet_id: 8f3a2b10-1c9e-4c2a-9b2f-3e6d2a1e9c11
              destination_type: mobile_money
              destination_ref: tok_dest_9f21ac
              amount: 500000
              currency: KES
      responses:
        '200':
          description: Payout created and queued for processing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientFunds'
        '403':
          $ref: '#/components/responses/ComplianceHold'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listPayouts
      tags: [Payouts]
      summary: List payouts
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            $ref: '#/components/schemas/PayoutStatus'
        - in: query
          name: wallet_id
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: A page of payouts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /payouts/bulk:
    post:
      operationId: createBulkPayout
      tags: [Payouts]
      summary: Initiate a batch of payouts
      description: Creates a payout_batch and its individual payouts in a single call, for paying many sub-merchants at once.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkPayoutCreateRequest'
      responses:
        '200':
          description: Batch created; individual payout statuses are available via GET /payouts?wallet_id=... or GET /payouts/batches/{id}.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutBatchEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientFunds'
        '403':
          $ref: '#/components/responses/ComplianceHold'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

  /payouts/batches/{id}:
    get:
      operationId: getPayoutBatch
      tags: [Payouts]
      summary: Retrieve a payout batch by ID
      description: Returns the batch, its aggregate status, and its individual payouts.
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The payout batch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutBatchEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /payouts/{id}:
    get:
      operationId: getPayout
      tags: [Payouts]
      summary: Retrieve a payout by ID
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: The payout.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayoutEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # Webhooks
  # ---------------------------------------------------------------------
  /webhooks:
    post:
      operationId: createWebhookEndpoint
      tags: [Webhooks]
      summary: Register a webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreateRequest'
            example:
              url: https://merchant.example.com/webhooks/wasaapay
              subscribed_events:
                - transaction.success
                - transaction.failed
                - payout.success
                - payout.failed
      responses:
        '200':
          description: Webhook endpoint registered. The signing secret is returned once and cannot be retrieved again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointCreateEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

    get:
      operationId: listWebhookEndpoints
      tags: [Webhooks]
      summary: List registered webhook endpoints
      responses:
        '200':
          description: A list of webhook endpoints.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointListEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /webhooks/{id}:
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhooks]
      summary: Remove a webhook endpoint
      parameters:
        - $ref: '#/components/parameters/ResourceId'
      responses:
        '200':
          description: Endpoint removed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ---------------------------------------------------------------------
  # FX
  # ---------------------------------------------------------------------
  /fx/rates:
    get:
      operationId: getFxRates
      tags: [FX]
      summary: Retrieve current FX rates
      parameters:
        - in: query
          name: base_currency
          required: true
          schema:
            type: string
            minLength: 3
            maxLength: 3
        - in: query
          name: quote_currency
          required: true
          schema:
            type: string
            minLength: 3
            maxLength: 3
      responses:
        '200':
          description: The current rate, inclusive of applied margin.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FxRateEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /fx/rate-locks:
    post:
      operationId: createFxRateLock
      tags: [FX]
      summary: Lock an FX rate for a scheduled future payout
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FxRateLockCreateRequest'
      responses:
        '200':
          description: Rate lock created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FxRateLockEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  # -----------------------------------------------------------------------
  # Security
  # -----------------------------------------------------------------------
  securitySchemes:
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-WasaaPay-Key
      description: >
        Merchant API key. Every request must also include X-WasaaPay-Signature,
        an HMAC-SHA256 signature computed over the timestamp and raw request
        body using the API secret associated with this key, and
        X-WasaaPay-Timestamp. See the Backend Engineering Document, Section 7.1.
    checkoutTokenAuth:
      type: apiKey
      in: header
      name: X-Checkout-Token
      description: >
        Hosted-checkout capability secret — the session's client_token, returned
        once by createCheckoutSession. Authorizes the payer-facing
        getCheckoutSession / payCheckoutSession endpoints in place of the merchant
        API key (the payer never holds merchant credentials). May alternatively be
        supplied as a client_token query parameter.

  # -----------------------------------------------------------------------
  # Shared parameters
  # -----------------------------------------------------------------------
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema:
        type: string
      description: A client-generated key unique per logical operation. Retried requests with the same key return the original result.
    ResourceId:
      in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    Cursor:
      in: query
      name: cursor
      schema:
        type: string
      description: Opaque pagination cursor from a previous response's meta.next_cursor.
    Limit:
      in: query
      name: limit
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    CheckoutToken:
      in: query
      name: client_token
      required: false
      schema:
        type: string
      description: >
        The session's client_token capability secret, as an alternative to the
        X-Checkout-Token header (see the checkoutTokenAuth security scheme).
        Supply exactly one of the two.

  # -----------------------------------------------------------------------
  # Shared responses (error catalog)
  # -----------------------------------------------------------------------
  responses:
    ValidationError:
      description: Request body failed schema validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            data: null
            error:
              code: VALIDATION_ERROR
              message: One or more fields failed validation.
              details:
                fields:
                  - field: amount
                    message: must be a positive integer
            meta:
              request_id: req_9f2c1a...
    IdempotencyConflict:
      description: The same Idempotency-Key was reused with a different request payload.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            data: null
            error:
              code: IDEMPOTENCY_CONFLICT
              message: This idempotency key was already used with a different payload.
            meta:
              request_id: req_9f2c1a...
    InsufficientFunds:
      description: The source wallet balance is too low for this operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            data: null
            error:
              code: INSUFFICIENT_FUNDS
              message: Wallet balance is insufficient for this payout.
            meta:
              request_id: req_9f2c1a...
    RailUnavailable:
      description: The requested payment rail is temporarily unavailable (circuit breaker open).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            data: null
            error:
              code: RAIL_UNAVAILABLE
              message: The mobile_money rail is temporarily unavailable. Retry later or use an alternate rail.
            meta:
              request_id: req_9f2c1a...
    NotFound:
      description: The requested resource does not exist or is not accessible to this merchant.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            data: null
            error:
              code: RESOURCE_NOT_FOUND
              message: No resource was found with the given ID.
            meta:
              request_id: req_9f2c1a...
    Unauthorized:
      description: API key missing, malformed, unknown, or signature mismatch.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: Merchant exceeded its API rate limit.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    ComplianceHold:
      description: Action blocked pending compliance review.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'

  # -----------------------------------------------------------------------
  # Schemas
  # -----------------------------------------------------------------------
  schemas:
    # --- Envelope primitives ---------------------------------------------
    Meta:
      type: object
      properties:
        request_id:
          type: string
        next_cursor:
          type: string
          nullable: true

    ErrorDetail:
      type: object
      properties:
        code:
          type: string
          enum:
            - AUTH_INVALID_KEY
            - AUTH_SIGNATURE_MISMATCH
            - VALIDATION_ERROR
            - IDEMPOTENCY_CONFLICT
            - INSUFFICIENT_FUNDS
            - RAIL_UNAVAILABLE
            - RATE_LIMITED
            - RESOURCE_NOT_FOUND
            - COMPLIANCE_HOLD
            - PERMISSION_DENIED
            - INTERNAL_ERROR
        message:
          type: string
        details:
          type: object
          additionalProperties: true

    NullableErrorDetail:
      type: object
      nullable: true
      description: Error detail object; always null on success responses.
      allOf:
        - $ref: '#/components/schemas/ErrorDetail'

    ErrorEnvelope:
      type: object
      properties:
        data:
          type: object
          nullable: true
          example: null
        error:
          $ref: '#/components/schemas/ErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    EmptyEnvelope:
      type: object
      properties:
        data:
          type: object
          nullable: true
          example: {}
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Enums -------------------------------------------------------------
    Rail:
      type: string
      enum: [mobile_money, card, bank, ussd, qr]

    TransactionStatus:
      type: string
      enum: [pending, success, failed, reversed]

    TransactionType:
      type: string
      enum: [collection, refund, transfer]

    KycStatus:
      type: string
      enum: [pending, verified, rejected, needs_review]

    KycTier:
      type: string
      enum: [tier_1, tier_2]

    PayoutStatus:
      type: string
      enum: [queued, processing, success, failed]

    DestinationType:
      type: string
      enum: [mobile_money, bank, card]

    SplitRuleType:
      type: string
      enum: [percentage, flat, tiered]

    RecipientType:
      type: string
      enum: [platform, sub_merchant, tax]

    EscrowReleaseCondition:
      type: string
      enum: [manual, auto_timeout, delivery_confirmation]

    WebhookEventType:
      type: string
      description: Canonical registry of webhook event types (dot.case, past-state).
      enum:
        - transaction.pending
        - transaction.success
        - transaction.failed
        - transaction.reversed
        - payout.queued
        - payout.processing
        - payout.success
        - payout.failed
        - escrow.held
        - escrow.released
        - escrow.returned
        - dispute.opened
        - dispute.resolved
        - kyc.verified
        - kyc.rejected

    # --- Transaction / Collection -------------------------------------------
    Transaction:
      type: object
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        sub_merchant_id:
          type: string
          format: uuid
          nullable: true
        type:
          $ref: '#/components/schemas/TransactionType'
        rail:
          $ref: '#/components/schemas/Rail'
        status:
          $ref: '#/components/schemas/TransactionStatus'
        amount:
          type: integer
          description: Minor currency units (e.g. cents).
        currency:
          type: string
          minLength: 3
          maxLength: 3
        parent_transaction_id:
          type: string
          format: uuid
          nullable: true
          description: For type=refund, the ID of the original collection this refund applies to; null otherwise.
        provider_reference:
          type: string
          nullable: true
        reference:
          type: string
          nullable: true
          description: Merchant-supplied order/reference string.
        action_url:
          type: string
          nullable: true
          description: >
            Payer redirect the client must send the payer to (e.g. a card 3-D
            Secure challenge) when the rail requires one; null otherwise.
        ussd_code:
          type: string
          nullable: true
          description: >
            USSD dial string the payer must complete (mobile-money fallback)
            when applicable; null otherwise.
        metadata:
          type: object
          additionalProperties: true
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    TransactionEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/Transaction'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    TransactionListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Transaction'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    CollectionCreateRequest:
      type: object
      required: [amount, currency, rail]
      properties:
        amount:
          type: integer
          minimum: 1
          description: Minor currency units (e.g. cents).
        currency:
          type: string
          minLength: 3
          maxLength: 3
        rail:
          $ref: '#/components/schemas/Rail'
        sub_merchant_id:
          type: string
          format: uuid
          nullable: true
        payer_phone:
          type: string
          nullable: true
          description: Required when rail = mobile_money or ussd.
        payer_card_token:
          type: string
          nullable: true
          description: Tokenized card reference from the certified card processor. Required when rail = card.
        payer_account_ref:
          type: string
          nullable: true
          description: Tokenized bank account reference from the certified provider. Required when rail = bank.
        reference:
          type: string
          nullable: true
          description: Merchant-supplied order/reference string, echoed back on the transaction.
        hold_in_escrow:
          type: boolean
          default: false
          description: >
            When true, the merchant leg of the settlement lands in the pending
            (escrow) bucket for later release by the split/escrow flow.

    RefundCreateRequest:
      type: object
      properties:
        amount:
          type: integer
          minimum: 1
          nullable: true
          description: >
            Refund amount in minor currency units. Omit (or send null) to
            refund the full remaining amount of the original collection. Must
            not exceed the original collection amount minus prior refunds.
        reason:
          type: string
          nullable: true
          maxLength: 500
          description: Optional merchant-supplied reason, stored on the refund transaction.

    # --- Hosted Checkout Sessions --------------------------------------------
    CheckoutSessionStatus:
      type: string
      enum: [open, completed, expired, cancelled]

    CheckoutSession:
      type: object
      description: >
        Full checkout session as returned by createCheckoutSession, including
        the one-time client_token capability secret. The token is shown ONCE at
        creation and cannot be retrieved afterwards — store it securely and hand
        it to the payer's checkout page.
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        sub_merchant_id:
          type: string
          format: uuid
          nullable: true
        amount:
          type: integer
          description: Minor currency units (e.g. cents).
        currency:
          type: string
          minLength: 3
          maxLength: 3
        reference:
          type: string
          nullable: true
          description: Merchant-supplied order/reference string.
        allowed_rails:
          type: array
          items:
            $ref: '#/components/schemas/Rail'
          description: Rails the payer may choose from at pay time.
        status:
          $ref: '#/components/schemas/CheckoutSessionStatus'
        client_token:
          type: string
          description: >
            Opaque payer-facing capability secret. Shown ONCE at creation and
            never returned by getCheckoutSession; treat it as a bearer secret.
        collection_intent_id:
          type: string
          format: uuid
          nullable: true
          description: The collection created when the session was paid; null until paid.
        expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CheckoutSessionPublic:
      type: object
      description: >
        Payer-facing checkout-session view returned by getCheckoutSession.
        Excludes the client_token and all merchant credentials.
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        amount:
          type: integer
          description: Minor currency units (e.g. cents).
        currency:
          type: string
          minLength: 3
          maxLength: 3
        reference:
          type: string
          nullable: true
        allowed_rails:
          type: array
          items:
            $ref: '#/components/schemas/Rail'
        status:
          $ref: '#/components/schemas/CheckoutSessionStatus'
        expires_at:
          type: string
          format: date-time

    CheckoutSessionCreateRequest:
      type: object
      required: [amount, currency]
      properties:
        amount:
          type: integer
          minimum: 1
          description: Minor currency units (e.g. cents).
        currency:
          type: string
          minLength: 3
          maxLength: 3
        reference:
          type: string
          nullable: true
          description: Merchant-supplied order/reference string, echoed on the resulting transaction.
        sub_merchant_id:
          type: string
          format: uuid
          nullable: true
        allowed_rails:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/Rail'
          description: Rails the payer may choose from. Defaults to all rails when omitted.

    CheckoutSessionPayRequest:
      type: object
      required: [rail]
      properties:
        rail:
          $ref: '#/components/schemas/Rail'
        payer_phone:
          type: string
          nullable: true
          description: Required when rail = mobile_money or ussd.
        payer_card_token:
          type: string
          nullable: true
          description: Tokenized card reference. Required when rail = card.
        payer_account_ref:
          type: string
          nullable: true
          description: Tokenized bank account reference. Required when rail = bank.

    CheckoutSessionEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/CheckoutSession'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    CheckoutSessionPublicEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/CheckoutSessionPublic'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Sub-Merchant --------------------------------------------------------
    SubMerchant:
      type: object
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        display_name:
          type: string
        type:
          type: string
          enum: [individual, business]
        country:
          type: string
          minLength: 2
          maxLength: 2
        kyc_tier:
          $ref: '#/components/schemas/KycTier'
        kyc_status:
          $ref: '#/components/schemas/KycStatus'
        status:
          type: string
          enum: [active, suspended]
        created_at:
          type: string
          format: date-time

    SubMerchantCreateRequest:
      type: object
      required: [display_name, type, country]
      properties:
        display_name:
          type: string
        type:
          type: string
          enum: [individual, business]
        country:
          type: string
          minLength: 2
          maxLength: 2
        kyc_tier:
          $ref: '#/components/schemas/KycTier'
        contact:
          type: object
          properties:
            phone:
              type: string
            email:
              type: string
              format: email

    SubMerchantEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/SubMerchant'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    SubMerchantListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SubMerchant'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Team ----------------------------------------------------------------
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        role:
          type: string
          enum: [admin, finance, read_only, compliance]
        mfa_enabled:
          type: boolean
        status:
          type: string
          enum: [active, disabled]

    UserListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/User'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Wallet --------------------------------------------------------------
    Wallet:
      type: object
      properties:
        id:
          type: string
          format: uuid
        owner_type:
          type: string
          enum: [merchant, sub_merchant, platform]
        owner_id:
          type: string
          format: uuid
        currency:
          type: string
          minLength: 3
          maxLength: 3
        available_balance:
          type: integer
          description: Minor currency units.
        pending_balance:
          type: integer
          description: Minor currency units (escrowed/uncleared).
        updated_at:
          type: string
          format: date-time

    WalletEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/Wallet'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    WalletListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Wallet'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Split Rules -----------------------------------------------------
    SplitRuleRecipient:
      type: object
      required: [recipient_type, value]
      properties:
        recipient_type:
          $ref: '#/components/schemas/RecipientType'
        recipient_id:
          type: string
          format: uuid
          nullable: true
          description: Required when recipient_type = sub_merchant.
        value:
          type: number
          format: double
          description: Percentage (0-100) if rule_type = percentage, otherwise a flat minor-unit amount.
        escrow:
          type: boolean
          default: false
        escrow_release_condition:
          $ref: '#/components/schemas/EscrowReleaseCondition'
        escrow_timeout_hours:
          type: integer
          nullable: true

    SplitRule:
      type: object
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        name:
          type: string
        rule_type:
          $ref: '#/components/schemas/SplitRuleType'
        active:
          type: boolean
        recipients:
          type: array
          items:
            $ref: '#/components/schemas/SplitRuleRecipient'
        updated_at:
          type: string
          format: date-time

    SplitRuleCreateRequest:
      type: object
      required: [name, rule_type, recipients]
      properties:
        name:
          type: string
        rule_type:
          $ref: '#/components/schemas/SplitRuleType'
        currency:
          type: string
          minLength: 3
          maxLength: 3
          nullable: true
          description: >
            Required for flat (fixed-amount) rules — the ISO 4217 currency the
            flat recipient values are denominated in. Omit for percentage rules.
        recipients:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/SplitRuleRecipient'

    SplitRuleEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/SplitRule'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    SplitRuleListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SplitRule'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Payouts -----------------------------------------------------------
    Payout:
      type: object
      properties:
        id:
          type: string
          format: uuid
        wallet_id:
          type: string
          format: uuid
        batch_id:
          type: string
          format: uuid
          nullable: true
        destination_type:
          $ref: '#/components/schemas/DestinationType'
        destination_ref:
          type: string
          description: Tokenized destination account reference.
        amount:
          type: integer
          description: Minor currency units.
        currency:
          type: string
          minLength: 3
          maxLength: 3
        status:
          $ref: '#/components/schemas/PayoutStatus'
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        retry_count:
          type: integer
        failure_reason:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time

    PayoutCreateRequest:
      type: object
      required: [wallet_id, destination_type, destination_ref, amount, currency]
      properties:
        wallet_id:
          type: string
          format: uuid
        destination_type:
          $ref: '#/components/schemas/DestinationType'
        destination_ref:
          type: string
          description: Tokenized destination account reference obtained via the payout-destination tokenization flow.
        amount:
          type: integer
          minimum: 1
        currency:
          type: string
          minLength: 3
          maxLength: 3
        scheduled_at:
          type: string
          format: date-time
          nullable: true

    BulkPayoutCreateRequest:
      type: object
      required: [wallet_id, payouts]
      properties:
        wallet_id:
          type: string
          format: uuid
          description: Source wallet shared by every payout in the batch.
        payouts:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: object
            required: [destination_type, destination_ref, amount, currency]
            properties:
              destination_type:
                $ref: '#/components/schemas/DestinationType'
              destination_ref:
                type: string
              amount:
                type: integer
                minimum: 1
              currency:
                type: string
                minLength: 3
                maxLength: 3

    PayoutBatch:
      type: object
      properties:
        id:
          type: string
          format: uuid
        merchant_id:
          type: string
          format: uuid
        total_amount:
          type: integer
        status:
          type: string
          enum: [processing, completed, partially_failed]
        payouts:
          type: array
          items:
            $ref: '#/components/schemas/Payout'

    PayoutEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/Payout'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    PayoutListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Payout'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    PayoutBatchEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/PayoutBatch'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- Webhooks ------------------------------------------------------------
    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        subscribed_events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        active:
          type: boolean
        created_at:
          type: string
          format: date-time

    WebhookEndpointCreateRequest:
      type: object
      required: [url, subscribed_events]
      properties:
        url:
          type: string
          format: uri
        subscribed_events:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/WebhookEventType'

    WebhookEndpointCreated:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          properties:
            signing_secret:
              type: string
              description: Shown once at creation time. Store it securely; it cannot be retrieved again.

    WebhookEndpointCreateEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/WebhookEndpointCreated'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    WebhookEndpointListEnvelope:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEndpoint'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    # --- FX --------------------------------------------------------------
    FxRate:
      type: object
      properties:
        base_currency:
          type: string
          minLength: 3
          maxLength: 3
        quote_currency:
          type: string
          minLength: 3
          maxLength: 3
        rate:
          type: number
          format: double
        margin_bps:
          type: integer
        effective_at:
          type: string
          format: date-time

    FxRateEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/FxRate'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'

    FxRateLockCreateRequest:
      type: object
      required: [base_currency, quote_currency]
      properties:
        base_currency:
          type: string
          minLength: 3
          maxLength: 3
        quote_currency:
          type: string
          minLength: 3
          maxLength: 3

    FxRateLock:
      type: object
      properties:
        id:
          type: string
          format: uuid
        base_currency:
          type: string
          minLength: 3
          maxLength: 3
        quote_currency:
          type: string
          minLength: 3
          maxLength: 3
        locked_rate:
          type: number
          format: double
        expires_at:
          type: string
          format: date-time
        used:
          type: boolean

    FxRateLockEnvelope:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/FxRateLock'
        error:
          $ref: '#/components/schemas/NullableErrorDetail'
        meta:
          $ref: '#/components/schemas/Meta'
