openapi: 3.1.0

info:
  title: Card Issuing Platform — Merchant API
  version: 1.0.0
  summary: The programmatic, machine-to-machine merchant surface of the card issuing platform.
  description: |
    The merchant v1 API covers **card issuing** (F007–F009) and **crypto deposit
    funding** (P010). Every endpoint rides the P004 HMAC gateway: requests are
    authenticated by an HMAC-SHA256 signature over a canonical string, protected
    against replay by a single-use nonce, and rate-limited per API key.

    **Conventions**
    - All monetary values are **integer minor units** (`*_minor`, e.g. cents). No floats.
    - All resource identifiers are **26-character ULID strings**. Internal autoincrement
      ids, provider ids, provider references, and provider credentials are **never**
      exposed (G-18). PAN/CVV and cardholder PII are never exposed or persisted (G-12/G-31).
    - Success and error envelopes follow `docs/conventions.md` (see `error-codes.md`).
    - List endpoints use **cursor pagination** (see `pagination.md`).

    **Excluded from this document**
    - Card PAN reveal (`POST /cards/{card}/reveal`) — authenticated-*user* surface
      (Sanctum + step-up 2FA), not the programmatic HMAC gateway.
    - Withdrawals — **no public API**; portal/admin only (F011/F015/F016).
    - Platform-admin (`/platform/*`), provider webhooks (`/webhooks/*`), and
      infrastructure endpoints (`/health`, `/v1/app/min-version`).

  contact:
    name: ScarabPay Business
    # The human reference is published at /developers/api on the platform's own
    # host; this document is served next to it at /developers/api/openapi.yaml.
    url: /developers/api

servers:
  - url: /api/v1
    description: Versioned merchant API base path

security:
  - HmacGateway: []

tags:
  - name: Cards
    description: Card issue (F007) and lifecycle operations (F008).
  - name: Card Transactions
    description: Provider-reported card activity for a card (F009).
  - name: Customers
    description: >-
      The cardholder KYC registry (F021). NOT mode-scoped: a customer registered
      with a test key is visible to a live key and can be issued a live card.
  - name: Deposit Addresses
    description: >-
      Permanent per-network crypto deposit addresses (P010). Live keys only.
  - name: Deposits
    description: >-
      Received crypto deposits credited to the merchant wallet (P010). Live keys
      only.

# ---------------------------------------------------------------------------
# paths
# ---------------------------------------------------------------------------
paths:
  # ── Cards ──────────────────────────────────────────────────────────────
  /cards:
    post:
      tags: [Cards]
      operationId: issueCard
      x-api-scope: cards.write
      summary: Issue a card
      description: |
        Issues a card for a merchant-enabled product via the hold→provider→settle
        saga (G-01). The merchant pays the initial load plus the product's issue
        fee. Idempotent: the `Idempotency-Key` header deduplicates the request
        (G-04) and is forwarded to the provider as the client reference. The
        cardholder identity comes from the customer registry (`customer_id`).
        Cards may provision **asynchronously** — see the 202 response.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id, amount_minor]
              properties:
                product_id:
                  type: string
                  minLength: 26
                  maxLength: 26
                  description: |
                    ULID of the product to issue against. OPTIONAL — omit it and
                    the card is issued against the platform's default product,
                    so a single-product integration never has to carry a catalog
                    id. When supplied it must be a well-formed ULID and must name
                    a product enabled for you, otherwise `404`. When omitted and
                    no default product is configured, the response is `422` with
                    `errors.product = ["no_default"]`.
                customer_id:
                  type: string
                  minLength: 26
                  maxLength: 26
                  description: |
                    ULID of the registry customer (see `/customers`) the card is
                    issued to. The customer's `kyc_status` must be `approved` —
                    issuing for an unverified customer returns `409` with
                    `errors.customer = ["kyc_not_approved"]`. The cardholder
                    name on the card is derived from the customer record.

                    If your account is configured for platform-managed
                    verification, omit this field: cardholder identity is
                    handled for you, and a `customer_id` sent anyway is ignored.
                amount_minor:
                  type: integer
                  format: int64
                  minimum: 0
                  description: |
                    Initial load in integer minor units, charged in addition to
                    the product's issue fee. Must be at least the product's
                    `min_initial_load_minor`.
            examples:
              minimal:
                summary: Explicit product
                value: { product_id: "01HXYZ...", customer_id: "01HABC...", amount_minor: 2500 }
              default_product:
                summary: Default product (no product_id)
                value: { customer_id: "01HABC...", amount_minor: 2500 }
      responses:
        '201':
          description: Card issued.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Card' }
        '202':
          description: |
            Issue accepted and processing (the issuer provisions cards
            asynchronously). When a card record exists already, `data` carries
            the card with `status: pending` — poll `GET /cards/{card}` until it
            becomes `active`. A replay of the same Idempotency-Key returns the
            card in its CURRENT state (201 once active).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProcessingEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: |
            Either the idempotency key is still in flight
            (`errors.idempotency = ["in_flight"]`), or the customer's
            verification is not approved
            (`errors.customer = ["kyc_not_approved"]`). The second is not a
            permanent failure: the same request succeeds unchanged once
            verification passes, so retry after the `customer.kyc.approved`
            webhook rather than treating it as a rejection.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '503':
          description: |
            Card issuing is temporarily unavailable. Retry shortly; nothing was
            charged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422':
          description: Validation failed, no product_id sent and no default product configured, customer_id required but absent, idempotency key reused with a different payload, insufficient funds, no enabled provider, or provider issue failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validation:
                  $ref: '#/components/examples/ValidationError'
                payloadMismatch:
                  $ref: '#/components/examples/IdempotencyPayloadMismatch'
                insufficientFunds:
                  $ref: '#/components/examples/InsufficientFunds'
                noDefaultProduct:
                  $ref: '#/components/examples/NoDefaultProduct'
                productUnavailable:
                  $ref: '#/components/examples/ProductUnavailable'
                issueFailed:
                  $ref: '#/components/examples/IssueFailed'
                initialLoadTooLow:
                  $ref: '#/components/examples/InitialLoadTooLow'
                customerInvalid:
                  $ref: '#/components/examples/CustomerInvalid'

  /cards/{card}:
    get:
      tags: [Cards]
      operationId: showCard
      x-api-scope: cards.read
      summary: Fetch a card
      description: |
        Returns the card in its current state — the poll target for an
        asynchronously provisioning issue (`status: pending` → `active`).
        Merchant-scoped: a foreign merchant's card returns 404.
      parameters:
        - $ref: '#/components/parameters/CardPath'
      responses:
        '200':
          description: The card.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Card' }
        '404': { $ref: '#/components/responses/NotFound' }

  /cards/{card}/recharge:
    post:
      tags: [Cards]
      operationId: rechargeCard
      x-api-scope: cards.write
      summary: Recharge (load funds onto) a card
      description: |
        Loads funds onto an active card via the recharge saga (merchant debit →
        provider settlement + revenue). Idempotent (`Idempotency-Key`). An ambiguous
        provider outcome returns **202**.
      parameters:
        - $ref: '#/components/parameters/CardPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                amount:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Amount to load, integer minor units.
      responses:
        '200':
          description: Card recharged.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Card' }
        '202':
          description: Ambiguous provider outcome — recharge is processing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProcessingEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Validation, insufficient funds, invalid card state, or idempotency conflict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validation:
                  $ref: '#/components/examples/ValidationError'
                insufficientFunds:
                  $ref: '#/components/examples/InsufficientFunds'
                invalidCardState:
                  $ref: '#/components/examples/InvalidCardState'

  /cards/{card}/freeze:
    post:
      tags: [Cards]
      operationId: freezeCard
      x-api-scope: cards.write
      summary: Freeze a card
      description: Freezes an active card (blocks cardholder spend). No money movement.
      parameters:
        - $ref: '#/components/parameters/CardPath'
      responses:
        '200':
          description: Card frozen.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Card' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Invalid card state (not active/frozen).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                invalidCardState:
                  $ref: '#/components/examples/InvalidCardState'

  /cards/{card}/unfreeze:
    post:
      tags: [Cards]
      operationId: unfreezeCard
      x-api-scope: cards.write
      summary: Unfreeze a card
      description: Reactivates a frozen card. No money movement.
      parameters:
        - $ref: '#/components/parameters/CardPath'
      responses:
        '200':
          description: Card reactivated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Card' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Invalid card state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                invalidCardState:
                  $ref: '#/components/examples/InvalidCardState'

  /cards/{card}/terminate:
    post:
      tags: [Cards]
      operationId: terminateCard
      x-api-scope: cards.write
      summary: Terminate a card
      description: |
        Terminates a card. The provider-reported remaining balance is refunded to the
        merchant wallet (G-06). Idempotent (`Idempotency-Key`). An ambiguous provider
        outcome returns **202**.
      parameters:
        - $ref: '#/components/parameters/CardPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  enum: [lost, stolen, fraud, no_longer_needed, other]
                  default: no_longer_needed
                  description: >-
                    Why the card is being terminated. A fixed vocabulary of ours,
                    never merchant free text — it is forwarded upstream.
      responses:
        '200':
          description: Card terminated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Card' }
        '202':
          description: Ambiguous provider outcome — terminate is processing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProcessingEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Invalid card state, too newly issued to terminate, or idempotency conflict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                invalidCardState:
                  $ref: '#/components/examples/InvalidCardState'
                terminationBlocked:
                  $ref: '#/components/examples/TerminationBlocked'

  /cards/{card}/transactions:
    get:
      tags: [Card Transactions]
      operationId: listCardTransactions
      x-api-scope: cards.read
      summary: List a card's transactions
      description: |
        Cursor-paginated, newest-first list of provider-reported card activity
        (auths, captures, refunds, reversals, declines) for a card. Merchant-scoped:
        a foreign merchant's card returns 404.
      parameters:
        - $ref: '#/components/parameters/CardPath'
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of card transactions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/CardTransaction' }
                      meta: { $ref: '#/components/schemas/CursorMeta' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ── Customers (cardholder KYC registry, F021) ──────────────────────────
  /customers:
    post:
      tags: [Customers]
      operationId: createCustomer
      x-api-scope: customers.write
      summary: Register a cardholder customer
      description: |
        Registers a cardholder identity (KYC) in the merchant's customer
        registry and starts their verification. Identity fields are **encrypted
        at rest**; the registry is strictly merchant-scoped.

        **Verification is asynchronous.** The response is `202` and the customer
        starts at `kyc_status: "unsubmitted"`, moving to `pending` once the
        verification is submitted. A card can only be issued for a customer at
        `kyc_status: "approved"` — issuing before then returns `409` with
        `errors.customer = ["kyc_not_approved"]`.

        Wait for the `customer.kyc.approved` webhook, or poll
        `GET /customers/{customer}`. A rejection arrives as
        `customer.kyc.rejected` with a `reason`; correct the details with
        `PATCH /customers/{customer}` and call
        `POST /customers/{customer}/kyc` to try again.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CustomerInput' }
      responses:
        '202':
          description: Customer registered; verification is in progress.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Customer' }
        '422':
          description: Validation failed (missing KYC fields, under-18 date of birth, malformed codes).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validation:
                  $ref: '#/components/examples/ValidationError'

    get:
      tags: [Customers]
      operationId: listCustomers
      x-api-scope: customers.read
      summary: List the merchant's customers
      description: Cursor-paginated, newest-first list of the merchant's registered cardholders.
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of customers.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Customer' }
                      meta: { $ref: '#/components/schemas/CursorMeta' }

  /customers/{customer}:
    get:
      tags: [Customers]
      operationId: showCustomer
      x-api-scope: customers.read
      summary: Fetch a customer
      parameters:
        - $ref: '#/components/parameters/CustomerPath'
      responses:
        '200':
          description: The customer.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Customer' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      tags: [Customers]
      operationId: updateCustomer
      x-api-scope: customers.write
      summary: Update a customer
      description: |
        Edits any subset of the customer's KYC fields, or sets `status` to
        `disabled`/`active`. A disabled customer cannot be attached to new card
        issues. Updates do **not** propagate to cards already issued.

        **Editing an identity field voids the verification.** A verification
        belongs to a person, not to a record, so changing a name, date of birth,
        id number, address, or any part of the verification profile sends the
        customer back through verification even if they were already approved:
        `kyc_status` returns to `pending` and they cannot back a *new* card
        issue until it passes again. Cards they already hold are unaffected.

        Changing `status` alone does **not** affect the verification —
        enabling and disabling a cardholder is your own switch.
      parameters:
        - $ref: '#/components/parameters/CustomerPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/CustomerInput'
                - type: object
                  properties:
                    status:
                      type: string
                      enum: [active, disabled]
      responses:
        '200':
          description: The updated customer.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Customer' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Validation failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validation:
                  $ref: '#/components/examples/ValidationError'

  /customers/{customer}/kyc:
    post:
      tags: [Customers]
      operationId: submitCustomerKyc
      x-api-scope: customers.write
      summary: Re-submit a customer's verification
      description: |
        Submits (or re-submits) the customer's identity for verification.

        Two cases need it. A customer whose verification was **rejected**:
        correct their details with `PATCH /customers/{customer}` first, then
        call this. And a customer registered before verification became a
        separate step, who therefore has no verification on record: add the
        required fields (`occupation`, `employment_status`, `account_purpose`,
        `annual_salary`, `expected_monthly_volume`,
        `terms_of_service_accepted`) with a `PATCH`, then call this.

        Safe to call more than once — an already-approved customer is returned
        unchanged with `200` rather than being re-verified.

        The request body is empty; the customer's stored details are used.
      parameters:
        - $ref: '#/components/parameters/CustomerPath'
      responses:
        '202':
          description: Verification submitted; the verdict follows asynchronously.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Customer' }
        '200':
          description: The customer is already approved; nothing was submitted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Customer' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: |
            The customer is missing fields required for verification
            (`errors.customer = ["kyc_incomplete"]`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  # ── Deposit Addresses ──────────────────────────────────────────────────
  /deposit-addresses:
    post:
      tags: [Deposit Addresses]
      operationId: createDepositAddress
      x-api-scope: deposits.write
      summary: Provision a permanent deposit address
      description: |
        Provisions (or returns) the **permanent** deposit address for a network.
        Idempotent per network: a re-POST for the same network returns the existing
        address (**200**) instead of creating a new one (**201**).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [network]
              properties:
                network:
                  type: string
                  examples: [trc20, erc20, bep20]
                  description: >-
                    One of the networks currently ENABLED on the platform. This is
                    operational configuration (the `funding_networks` table), not a
                    fixed enum: an operator can add or disable a network without an
                    API version change. A network that is off yields 422 whose
                    message names the ones that are on.
      responses:
        '201':
          description: New address provisioned.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/DepositAddress' }
        '200':
          description: Existing address returned (idempotent re-POST).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/DepositAddress' }
        '403': { $ref: '#/components/responses/LiveOnly' }
        '422':
          description: Validation failed (the network is not currently enabled).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                validation:
                  $ref: '#/components/examples/ValidationError'

    get:
      tags: [Deposit Addresses]
      operationId: listDepositAddresses
      x-api-scope: deposits.read
      summary: List the merchant's deposit addresses
      description: |
        Returns the merchant's permanent deposit addresses, ordered by network.
        **Not paginated** — bounded to at most one address per supported network
        (`UNIQUE(merchant_id, network)` ⇒ ≤3 rows). See `pagination.md`.
      responses:
        '200':
          description: The merchant's deposit addresses.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/DepositAddress' }
        '403': { $ref: '#/components/responses/LiveOnly' }

  # ── Deposits ───────────────────────────────────────────────────────────
  /deposits:
    get:
      tags: [Deposits]
      operationId: listDeposits
      x-api-scope: deposits.read
      summary: List received deposits
      description: |
        Cursor-paginated, newest-first list of received crypto deposits credited to
        the merchant wallet. Merchant-scoped.
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of deposits.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Deposit' }
                      meta: { $ref: '#/components/schemas/CursorMeta' }
        '403': { $ref: '#/components/responses/LiveOnly' }

# ---------------------------------------------------------------------------
# components
# ---------------------------------------------------------------------------
components:
  securitySchemes:
    HmacGateway:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        **HMAC-SHA256 request signing (P004 gateway).** Every request must carry
        four headers and be signed with the API key's secret:

        | Header | Purpose |
        |---|---|
        | `X-Api-Key` | The public key id (ULID). |
        | `X-Timestamp` | Unix seconds (UTC). Rejected if `abs(now − timestamp) > 300s`. |
        | `X-Nonce` | Client-generated unique value (UUID v4 recommended). Single-use. |
        | `X-Signature` | `base64(hmac_sha256(secret, canonical_string))`. |

        **Canonical string** (lines joined with `\n`, no trailing newline):
        ```
        METHOD
        PATH
        QUERY_STRING
        X-Api-Key
        X-Timestamp
        X-Nonce
        hex(sha256(raw_body))
        ```
        - `METHOD` uppercase.
        - `PATH` is the **FULL request path, including the `/api/v1` prefix** —
          e.g. `/api/v1/cards`, not `/cards`. The `servers` entry above is
          relative for path-templating only; the signature always covers the
          whole path. This is the single most common signing mistake.
        - `PATH` carries no query string and no trailing slash.
        - `QUERY_STRING` sorted by key, `k=v` URL-encoded, empty string if none.
          Repeated params are `k[]=v` with their values sorted. A space encodes
          as `+`, not `%20`.
        - `hex(sha256(raw_body))` — hash of the raw body (`sha256('')` for empty bodies).

        The secret is 32 random bytes, base64 (44 chars). It is **encrypted at
        rest** and shown to the merchant exactly once at key creation. See
        `docs/completed/P004-api-gateway.md`, and `/developers/api` for a worked
        example with a reproducible signature.

        **Authorization failures (both AFTER a valid signature, so they never
        confirm a key to a caller who lacks the secret):**

        | HTTP | `errors` | Trigger |
        |---|---|---|
        | 403 | `{"gateway": ["ip_not_allowed"]}` | The source address is not on the key's IP allowlist. |
        | 403 | `{"gateway": ["out_of_scope"]}` | The key does not hold the scope the route declares (see each operation's `x-api-scope`). |

  parameters:
    CardPath:
      name: card
      in: path
      required: true
      schema:
        type: string
        minLength: 26
        maxLength: 26
      description: ULID of the card. A foreign merchant's card returns 404.
    CustomerPath:
      name: customer
      in: path
      required: true
      schema:
        type: string
        minLength: 26
        maxLength: 26
      description: ULID of the customer. A foreign merchant's customer returns 404.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, maxLength: 255 }
      description: |
        Required on all mutating money operations (issue, recharge, terminate).
        Deduplicates the request (G-04); a replay returns the original response, a
        concurrent duplicate returns 409, and reuse with a different payload returns 422.
    PerPage:
      name: per_page
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      description: Page size. Clamped to the range [1, 100]; defaults to 20.
    Cursor:
      name: cursor
      in: query
      required: false
      schema: { type: string }
      description: Opaque cursor returned in `meta.next_cursor` / `meta.previous_cursor`.

  responses:
    NotFound:
      description: Resource not found (or belongs to another merchant / is disabled).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            notFound:
              value:
                success: false
                message: Resource not found.
                errors: {}
    LiveOnly:
      description: >-
        A TEST-mode key called a live-only endpoint. There is no simulated
        chain, so the funding endpoints are live keys only (F022) — a merchant's
        test wallet is funded for them instead.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            liveOnly:
              value:
                success: false
                message: Deposits are live-only.
                errors: {}
    IdempotencyInFlight:
      description: A request with this Idempotency-Key is already in progress.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          examples:
            inFlight:
              value:
                success: false
                message: A request with this Idempotency-Key is already in progress.
                errors: { idempotency: [in_flight] }

  schemas:
    # ── Envelopes ────────────────────────────────────────────────────────
    SuccessEnvelope:
      type: object
      required: [success]
      properties:
        success: { type: boolean, example: true }
        data: {}
        message: { type: string }
    ErrorEnvelope:
      type: object
      required: [success, message, errors]
      properties:
        success: { type: boolean, example: false }
        message: { type: string }
        errors:
          description: Either an empty object `{}` or a map of `field/key → [code]`.
          type: object
          additionalProperties:
            type: array
            items: { type: string }
    ProcessingEnvelope:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, example: true }
        message: { type: string, example: "Card issue is processing." }
        data:
          type: object
          required: [status]
          properties:
            status: { type: string, enum: [pending] }

    CursorMeta:
      type: object
      required: [per_page, has_more, next_cursor, previous_cursor]
      properties:
        per_page: { type: integer, example: 20 }
        has_more: { type: boolean, example: false }
        next_cursor:
          type: string
          nullable: true
        previous_cursor:
          type: string
          nullable: true

    # ── Resources ────────────────────────────────────────────────────────
    Card:
      type: object
      required: [id, product_id, status, mode, masked_pan, last4, expiry, currency, balance_minor, created_at]
      properties:
        id:
          type: string
          minLength: 26
          maxLength: 26
          description: ULID. The provider id, provider card id, and provider identity are never exposed (G-18).
        product_id:
          type: string
          minLength: 26
          maxLength: 26
        customer_id:
          type: string
          minLength: 26
          maxLength: 26
          nullable: true
          description: ULID of the registry customer the card was issued to (null for legacy cards).
        status:
          type: string
          enum: [pending, active, frozen, terminated, expired, failed]
        mode:
          type: string
          enum: [test, live]
          description: >-
            The mode of the API key that created the card (F022, G-32). A test-mode
            key issues cards on a simulated provider with segregated play money,
            excluded from platform reports. This is the merchant's own test/live
            vocabulary — never a provider identity (G-18). The response shape is
            identical between test and live.
        masked_pan:
          type: string
          description: Masked PAN. Full PAN/CVV is never exposed (G-12).
        last4:
          type: string
        expiry:
          type: string
          nullable: true
        currency:
          type: string
          example: USD
        balance_minor:
          type: integer
          format: int64
          minimum: 0
          description: Current card balance, integer minor units.
        created_at:
          type: string
          format: date-time
          nullable: true

    CustomerInput:
      type: object
      description: |
        Cardholder KYC fields. Held in the merchant's registry and submitted to
        the card issuer for verification. Every identity field is stored
        **encrypted at rest** except `first_name` and `last_name`, which are
        stored in the clear so support staff can find a cardholder by name. All
        fields required on create; any subset on update.
      required: [first_name, last_name, email, phone_number, dial_code, date_of_birth, id_type, id_number, line1, city, state, postal_code, country, occupation, employment_status, account_purpose, annual_salary, expected_monthly_volume, terms_of_service_accepted]
      properties:
        customer_type:
          type: string
          enum: [individual, business]
          default: individual
          description: |
            `business` additionally requires `business_name` and
            `registration_number`. It does **not** replace the personal fields —
            the natural person behind the account is verified either way, so
            `first_name`, `last_name`, `date_of_birth` and an identity document
            are still required.
        business_name:
          type: string
          maxLength: 150
          description: The registered business name. Required when `customer_type` is `business`.
        registration_number:
          type: string
          maxLength: 64
          description: The business or RC number. Required when `customer_type` is `business`.
        first_name: { type: string, maxLength: 100 }
        last_name: { type: string, maxLength: 100 }
        email: { type: string, format: email, maxLength: 254 }
        phone_number:
          type: string
          pattern: '^\d{4,15}$'
          description: Digits only, local format without the country code.
        dial_code:
          type: string
          pattern: '^\+\d{1,4}$'
          example: '+234'
        date_of_birth:
          type: string
          format: date
          description: YYYY-MM-DD. The cardholder must be at least 18 years old.
        id_type:
          type: string
          enum: [bvn, nin, passport, drivers_license, national_id, vnin, voters_card, ghana_card]
          description: |
            `bvn` and `nin` are checked against a Nigerian national database:
            they verify in seconds, require `country` to be `NGA`, and must NOT
            be sent with `id_front_image`.

            Every other type is a document: it REQUIRES `id_front_image`, and a
            result comes back within about 10 minutes.
        id_number: { type: string, maxLength: 64 }
        id_front_image:
          type: string
          maxLength: 4200000
          description: |
            Base64-encoded photo of the FRONT of the document, in full, in focus
            and in date — blurred, cropped or expired images are the most common
            cause of rejection.

            Required for document `id_type` values and rejected for `bvn`/`nin`.
            Compressed automatically if it exceeds the limit, so send the photo you have rather than resizing it yourself. Held on private storage only until the verification settles, then deleted.
        line1: { type: string, maxLength: 120 }
        city: { type: string, maxLength: 120 }
        state: { type: string, maxLength: 120 }
        postal_code: { type: string, maxLength: 20 }
        country:
          type: string
          minLength: 3
          maxLength: 3
          description: ISO 3166-1 alpha-3 (e.g. USA, NGA). Case-insensitive on input.
        occupation:
          type: string
          maxLength: 100
          description: The cardholder's occupation, in snake_case (e.g. `software_engineer`).
        employment_status:
          type: string
          maxLength: 50
          example: employed
        account_purpose:
          type: string
          maxLength: 100
          description: What the card will be used for (e.g. `personal_spending`).
        annual_salary:
          type: string
          pattern: '^\d{1,15}$'
          description: Declared annual income in whole units, digits only.
        expected_monthly_volume:
          type: string
          pattern: '^\d{1,15}$'
          description: Anticipated monthly card spend in whole units, digits only.
        place_of_birth:
          type: string
          maxLength: 120
          description: Optional.
        terms_of_service_accepted:
          type: boolean
          description: |
            Must be `true`. Records the cardholder's acceptance of the terms of
            service; verification cannot be submitted without it.

    Customer:
      type: object
      required: [id, customer_type, first_name, last_name, email, status, created_at]
      properties:
        id:
          type: string
          minLength: 26
          maxLength: 26
          description: ULID.
        customer_type:
          type: string
          enum: [individual, business]
        business_name: { type: string, nullable: true }
        registration_number: { type: string, nullable: true }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        phone_number: { type: string }
        dial_code: { type: string }
        date_of_birth: { type: string, format: date }
        id_type: { type: string }
        id_number_last4:
          type: string
          description: Last 4 of the id number. The full id number is never echoed back.
        line1: { type: string }
        city: { type: string }
        state: { type: string }
        postal_code: { type: string }
        country: { type: string }
        status:
          type: string
          enum: [active, disabled]
          description: |
            Your own switch. `disabled` stops the customer backing NEW card
            issues; their existing cards keep working. Independent of
            `kyc_status` — disabling an approved customer does not discard
            their verification.
        kyc_status:
          type: string
          enum: [unsubmitted, pending, approved, rejected]
          description: |
            The issuer's verification verdict. **Only `approved` can back a
            card issue.**

            - `unsubmitted` — nothing verified yet, or details are incomplete.
            - `pending` — submitted, awaiting the verdict.
            - `approved` — verified; the customer can be used to issue cards.
            - `rejected` — verification failed; see `kyc_failure_reason`.

            Changes arrive as a `customer.kyc.*` webhook.
        kyc_failure_reason:
          type: string
          nullable: true
          description: Set when `kyc_status` is `rejected` — the issuer's rejection label.
        kyc_submitted_at:
          type: string
          format: date-time
          nullable: true
        kyc_resolved_at:
          type: string
          format: date-time
          nullable: true
          description: When the verification reached a final verdict.
        occupation: { type: string, nullable: true }
        employment_status: { type: string, nullable: true }
        account_purpose: { type: string, nullable: true }
        annual_salary: { type: string, nullable: true }
        expected_monthly_volume: { type: string, nullable: true }
        place_of_birth: { type: string, nullable: true }
        created_at:
          type: string
          format: date-time
          nullable: true

    CardTransaction:
      type: object
      required: [id, type, status, amount_minor, currency, occurred_at]
      properties:
        id:
          type: string
          minLength: 26
          maxLength: 26
          description: ULID. The internal `provider_transaction_id` and `card_id` are never exposed.
        type:
          type: string
          enum: [authorization, capture, refund, reversal, decline]
        status:
          type: string
          enum: [pending, completed, declined, reversed]
        amount_minor:
          type: integer
          format: int64
          description: Integer minor units.
        currency:
          type: string
        occurred_at:
          type: string
          format: date-time
          nullable: true

    DepositAddress:
      type: object
      required: [id, network, asset, address, memo, created_at]
      properties:
        id:
          type: string
          minLength: 26
          maxLength: 26
          description: ULID. The provider driver key and provider reference are never exposed (G-18).
        network:
          type: string
          enum: [trc20, erc20, bep20]
        asset:
          type: string
          example: USDT
        address:
          type: string
          description: The on-chain deposit address.
        memo:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true

    Deposit:
      type: object
      required: [id, network, asset, asset_amount, credited_amount_minor, currency, rate, status, confirmed_at]
      properties:
        id:
          type: string
          minLength: 26
          maxLength: 26
          description: ULID. The `deposit_provider`, `provider_deposit_id`, and `transaction_id` are never exposed (G-18).
        network:
          type: string
        asset:
          type: string
          example: USDT
        asset_amount:
          type: string
          description: Exact crypto amount received, as a decimal string.
        credited_amount_minor:
          type: integer
          format: int64
          minimum: 0
          description: Amount credited to the wallet, integer minor units.
        currency:
          type: string
          example: USD
        rate:
          type: string
          description: USDT→USD rate applied, as a decimal string.
        status:
          type: string
          enum: [pending, credited, flagged]
          description: "`flagged` is credited but marked for admin review (partial/over/late — G-09)."
        tx_id:
          type: string
          nullable: true
          description: >-
            The on-chain transaction reference, for matching against the
            merchant's own chain records. Present once the deposit has been
            attributed to a transaction.
        confirmed_at:
          type: string
          format: date-time
          nullable: true

  examples:
    ValidationError:
      summary: 422 validation failure
      value:
        success: false
        message: Validation failed.
        errors: { customer_id: ["The customer id field is required."] }
    IdempotencyPayloadMismatch:
      summary: 422 idempotency key reused with a different payload
      value:
        success: false
        message: Idempotency-Key was reused with a different payload.
        errors: { idempotency: [payload_mismatch] }
    InsufficientFunds:
      summary: 422 insufficient wallet balance
      value:
        success: false
        message: Insufficient wallet balance.
        errors: { balance: [insufficient_funds] }
    NoDefaultProduct:
      summary: 422 no product_id sent and no default product configured
      value:
        success: false
        message: No product_id was supplied and no default product is available.
        errors: { product: [no_default] }
    ProductUnavailable:
      summary: 422 product has no enabled provider
      value:
        success: false
        message: This product is not currently available.
        errors: { product: [unavailable] }
    IssueFailed:
      summary: 422 provider could not issue the card
      value:
        success: false
        message: The card could not be issued. Please try again.
        errors: { card: [issue_failed] }
    InitialLoadTooLow:
      summary: 422 initial load below the product minimum
      value:
        success: false
        message: The initial load is below this product's minimum.
        errors: { amount_minor: [below_minimum] }
    CustomerInvalid:
      summary: 422 the customer cannot back an issue (e.g. disabled)
      value:
        success: false
        message: This customer cannot be used for card issue.
        errors: { customer: [invalid] }
    TerminationBlocked:
      summary: 422 card is too newly issued to terminate
      value:
        success: false
        message: This card cannot be terminated yet — freeze it instead and retry later.
        errors: { card: [termination_blocked] }
    InvalidCardState:
      summary: 422 card cannot perform the operation in its current state
      value:
        success: false
        message: This card cannot perform that operation in its current state.
        errors: { card: [invalid_state] }
