> ## Documentation Index
> Fetch the complete documentation index at: https://docs.suby.fi/llms.txt
> Use this file to discover all available pages before exploring further.

# Debit a stored card, off-session

> 
One shape. Crypto moved to `POST /v3/crypto/charges`, whose mechanics share
almost nothing with a card debit.

**Off-session debit** · charge an instrument already stored here:
`offSession: true`, `customer.id`, and the `pi_…` from
`GET /v3/customers/{id}/payment-methods`. This is the pay-as-you-go path:
metered billing, account top-ups, a threshold crossed.

**It always names a per-use product.** `productId` (a product created with
`billingMode: pay_as_you_go`) and `priceCents` are both required: the
product says what was consumed, the amount says how much. This is the exact
mirror of `mode=setup` on a checkout session, which refuses a priced product
for the same reason · the card was stored to be debited per use, so what it
is debited for is a per-use product. A `onetime` or `subscription` product
answers 422 `PRODUCT_NOT_PRICED_PER_USE`.

A card charge with the buyer present is **refused**. Collecting a card needs
a page that renders card fields, and that page is ours · open a checkout
session instead. There is no way to hand this endpoint a card token.




## OpenAPI

````yaml /v3-beta/api-reference/openapi.yaml post /v3/payments
openapi: 3.1.0
info:
  title: Suby.fi Merchant API
  version: 3.0.0-beta
  description: >
    The **v3** public merchant API for Suby.fi. RESTful (plural,
    resource-oriented

    endpoints under the `/v3` prefix). Authenticate every request

    with your secret API key in the `X-Suby-Api-Key` header.


    - `sk_live_…` keys operate in **production** (real funds).

    - `sk_sandbox_…` keys operate in a fully simulated **sandbox** (test cards,
      Base Sepolia crypto, no real money). The environment is derived from the
      key prefix.
  contact:
    email: contact@suby.fi
    url: https://suby.fi
servers:
  - url: https://api.beta.suby.fi
    description: Production + sandbox (environment selected by API key prefix)
security:
  - ApiKeyAuth: []
tags:
  - name: Products
    description: Manage your product catalog (one-time and recurring).
  - name: Checkout
    description: >-
      Hosted checkout · signed session tokens (`cs_…`). Every card Suby holds
      was collected here, which is what keeps card data out of your systems.
  - name: Payments
    description: >-
      Read payments and receipts, refund them, and debit a stored instrument
      off-session (pay-as-you-go). Collecting a card happens on the hosted
      checkout, never here.
  - name: Subscriptions
    description: >-
      Drive a live subscription · read it, change its plan, end it.
      Subscriptions are opened by a checkout session, not by this API.
  - name: Discount Codes
    description: Promo codes, redeemed at checkout.
  - name: Crypto
    description: >-
      The headless rail · quote, charge, and settle on-chain from inside your
      own site. No card data is involved, so none of the constraints that keep
      card collection on a Suby page apply here.
  - name: Customers
    description: First-class customer records with billing address.
  - name: Payment Methods
    description: >-
      Read and detach the cards a customer has stored. They are created by a
      checkout session (`savePaymentMethod`, or `mode=setup`) · the only place a
      card is collected.
  - name: Licence Key
    description: >-
      The licence server · validate a key, take a seat, free one. Authenticated
      with your API key like everything else, and scoped to the licences you
      issued, so your backend relays the check rather than your customer's
      software calling it directly.
paths:
  /v3/payments:
    post:
      tags:
        - Payments
      summary: Debit a stored card, off-session
      description: >

        One shape. Crypto moved to `POST /v3/crypto/charges`, whose mechanics
        share

        almost nothing with a card debit.


        **Off-session debit** · charge an instrument already stored here:

        `offSession: true`, `customer.id`, and the `pi_…` from

        `GET /v3/customers/{id}/payment-methods`. This is the pay-as-you-go
        path:

        metered billing, account top-ups, a threshold crossed.


        **It always names a per-use product.** `productId` (a product created
        with

        `billingMode: pay_as_you_go`) and `priceCents` are both required: the

        product says what was consumed, the amount says how much. This is the
        exact

        mirror of `mode=setup` on a checkout session, which refuses a priced
        product

        for the same reason · the card was stored to be debited per use, so what
        it

        is debited for is a per-use product. A `onetime` or `subscription`
        product

        answers 422 `PRODUCT_NOT_PRICED_PER_USE`.


        A card charge with the buyer present is **refused**. Collecting a card
        needs

        a page that renders card fields, and that page is ours · open a checkout

        session instead. There is no way to hand this endpoint a card token.
      operationId: createPayment
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePaymentBody'
      responses:
        '201':
          description: Payment created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - properties:
                      data:
                        $ref: '#/components/schemas/CreatePaymentResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        Optional key (≤255 chars, e.g. a UUID v4) that makes this POST safe to
        retry: the first request executes and its response is cached for 24h; a
        retry with the SAME key replays that response instead of re-executing
        (no duplicate payment/subscription). A reused key with a different
        request → `422 IDEMPOTENCY_KEY_CONFLICT`; a retry while the first is
        still in flight → `409`. See the Idempotency guide.
      schema:
        type: string
        maxLength: 255
        example: 5f3b9c2e-1a4d-4f2b-9c31-7e2a1b6d8c04
  schemas:
    CreatePaymentBody:
      type: object
      required:
        - method
        - customer
        - productId
        - priceCents
      description: >-
        **This endpoint charges metered usage, and nothing else.** One shape is
        accepted: an off-session debit (`offSession: true` with
        `customerPaymentMethodId`) of a product created with `billingMode:
        pay_as_you_go`. A card charge with the buyer present is refused ·
        collecting a card needs a page that renders card fields, and that page
        is the hosted checkout (`POST /v3/checkout/sessions`). Crypto has its
        own namespace, `POST /v3/crypto/charges`.


        **Both halves are required.** `productId` says WHAT was consumed,
        `priceCents` says how much. A per-use product carries no price of its
        own, so neither can be inferred from the other. A product the catalog
        already prices — `onetime` or `subscription` — is refused with
        `PRODUCT_NOT_PRICED_PER_USE`: debiting a `onetime` product off-session
        bills a customer again for something they authorised once, and a
        `subscription` product for a cycle Suby's own clock already bills. Both
        would succeed at the acquirer, which is why they are refused here.


        Every payment is attributed to a customer; there are no anonymous
        charges.


        **The metered flow**: collect a card once with a `mode: "setup"`
        checkout session, then debit it here as usage accrues, one
        `Idempotency-Key` per billing period. The product's shared link
        (`/p/{productId}`) opens that setup session on its own, so a per-use
        product is sellable without writing the session call yourself.
      properties:
        productId:
          type: string
          example: pro_abc123
          description: >-
            The per-use product this usage belongs to. REQUIRED · it is what
            puts the charge in the product's revenue and analytics, and what
            `product_id` carries on the outbound `payment.succeeded`. Must be
            `billingMode: pay_as_you_go`; anything else answers 422
            `PRODUCT_NOT_PRICED_PER_USE`.
        priceCents:
          type: string
          pattern: ^[1-9]\d*$
          example: '1999'
          description: >-
            What was consumed, in minor units, as a string of digits · `"1999"`
            = 19.99. REQUIRED · the product carries no price for this call to
            fall back on. The currency comes from the product.
        currency:
          type: string
          pattern: ^[A-Z]{3}$
          description: >-
            ISO 4217. Optional · the product's own currency is authoritative.
            Sending a DIFFERENT one answers 422 `PRODUCT_CURRENCY_MISMATCH`
            rather than being rebased: the two readings differ by an exchange
            rate, and one of them debits a real customer the wrong amount.
        displayName:
          type: string
          maxLength: 200
          description: >-
            Label snapshotted on the Payment · what the payer reads on the
            receipt for this period ("April usage"). Falls back to the product
            name.
        displayDescription:
          type: string
          maxLength: 500
          nullable: true
        displayImageUrl:
          type: string
          format: uri
          nullable: true
        taxBehavior:
          type: string
          enum:
            - inclusive
            - exclusive
          description: >-
            MoR VAT: `exclusive` prices HT and adds VAT on top; `inclusive`
            treats the price as VAT-inclusive. Omit it to take the product's own
            `taxBehavior` (an ad-hoc amount with no product falls back to
            `exclusive`). Ignored when no VAT applies.
        customer:
          $ref: '#/components/schemas/CustomerRef'
        billingAddress:
          $ref: '#/components/schemas/BillingAddress'
          description: >-
            Required by the PSP when the stored customer has none (acquirers
            reject an empty country).
        businessData:
          $ref: '#/components/schemas/BusinessData'
        customerPaymentMethodId:
          type: string
          example: pi_abc123
          description: >-
            The stored instrument to debit (`pi_…`, from GET
            /v3/customers/{id}/payment-methods). Required when offSession=true.
        method:
          $ref: '#/components/schemas/PaymentMethodType'
        offSession:
          type: boolean
          default: false
          description: >-
            Debit a stored instrument with no customer present. Required · it is
            the only shape this endpoint accepts. Needs `customer.id` +
            `customerPaymentMethodId`. Sent to the card networks as an ON-DEMAND
            merchant-initiated transaction (the amount and date were not agreed
            in advance); subscription cycles carry the recurring indicator
            instead, and neither is settable from this call.
        externalRef:
          type: string
          maxLength: 120
          description: Your reference, stored on the Payment for reconciliation.
        metadata:
          $ref: '#/components/schemas/Metadata'
    SuccessEnvelope:
      type: object
      required:
        - success
        - data
      properties:
        success:
          type: boolean
          example: true
        message:
          type: string
        data:
          description: Endpoint-specific payload.
    CreatePaymentResponse:
      type: object
      properties:
        payment:
          $ref: '#/components/schemas/Payment'
        checkout:
          type: object
          nullable: true
          description: Card/APM handoff (mutually exclusive with `instruction`).
          properties:
            providerPaymentId:
              type: string
            redirectUrl:
              type: string
              nullable: true
            threeDsChallengeUrl:
              type: string
              nullable: true
            clientSecret:
              type: string
              nullable: true
        instruction:
          $ref: '#/components/schemas/PaymentInstruction'
    CustomerRef:
      type: object
      description: >-
        How a customer is named in a request body: exactly one of `id` or
        `email`. With `id` the name fields are ignored · the customer already
        has them on file. With `email` the customer is created on the fly (email
        is the get-or-create key). Supplying both, or neither, is a validation
        error.
      properties:
        id:
          type: string
          example: cus_abc123
          description: An existing customer.
        email:
          type: string
          format: email
          maxLength: 320
          description: Creates the customer if no one holds this address.
        firstName:
          type: string
          maxLength: 100
        lastName:
          type: string
          maxLength: 100
    BillingAddress:
      type: object
      properties:
        line1:
          type: string
          maxLength: 200
          nullable: true
        line2:
          type: string
          maxLength: 200
          nullable: true
        city:
          type: string
          maxLength: 120
          nullable: true
        state:
          type: string
          maxLength: 120
          nullable: true
        postalCode:
          type: string
          maxLength: 40
          nullable: true
        country:
          type: string
          description: ISO 3166-1 alpha-2.
          pattern: ^[A-Z]{2}$
          nullable: true
    BusinessData:
      type: object
      description: >-
        B2B purchase details. Accepted on ALL accounts · `businessName` /
        `taxId` are always stored on the customer, and `businessName` doubles as
        the card holder name. The VAT **reverse-charge** it triggers (0% VAT
        except France) only takes effect on Merchant-of-Record accounts; on
        non-MoR accounts no VAT applies at all, so it has no tax effect.
      properties:
        purchaseAsBusiness:
          type: boolean
          description: Marks the purchase as B2B (drives the MoR VAT reverse-charge).
        businessName:
          type: string
          maxLength: 200
          nullable: true
        taxId:
          type: string
          maxLength: 60
          nullable: true
          description: Buyer VAT / fiscal number.
    PaymentMethodType:
      type: string
      enum:
        - CARD
        - APPLE_PAY
        - GOOGLE_PAY
        - KLARNA
        - IDEAL
        - BANCONTACT
        - TWINT
        - BLIK
        - AFFIRM
        - ALMA
        - BILLIE
        - SCALAPAY
        - MULTIBANCO
        - PAYPAL
        - SEPA_DIRECT_DEBIT
        - ACH_DIRECT_DEBIT
        - CRYPTO
    Metadata:
      type: object
      additionalProperties:
        type: string
        nullable: true
      description: |
        Key-value pairs. ≤50 keys, keys ≤40 chars, values are
        strings ≤500 chars (or `null` to clear). Nested structures must be
        JSON-stringified into a single string value.
    Payment:
      type: object
      description: >
        Public payment shape (`PaymentPublic`).


        **The response is scoped to its own rail.** `rail` says which: on
        `crypto` the

        card-only fields (`declineCode`, `declineCategory`, `declineAdvice`,

        `declineNetworkCode`, `threeDSecureResult`) are ABSENT, and on `card`
        the

        chain fields (`tokenAmount`, `tokenFeeAmount`, `quoteFiatAmountCents`,

        `quoteFiatCurrency`, `cryptoSettlementMode`, `settlementChainId`,

        `settlementAsset`, `settlementRecipient`, `depositAddress`,

        `settlementTxHash`, `lifiTxHash`, `lifiSubstatus`) are. Absent, not null
        · a

        field that could only ever read `null` on your rail is not "unknown
        yet", and

        branching on it is branching on a case that cannot happen.
      properties:
        rail:
          type: string
          enum:
            - card
            - crypto
          description: >-
            Card or chain · which half of this shape the payment fills in. Read
            it instead of inferring the rail from a null field.
        id:
          type: string
          example: pay_abc123
        organizationId:
          type: string
        customerId:
          type: string
          nullable: true
        productId:
          type: string
          nullable: true
        subscriptionId:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/PaymentStatus'
        paymentMethodCategory:
          type: string
          nullable: true
          enum:
            - CARD
            - AMEX
            - COMMERCIAL_CARD
            - APPLE_PAY
            - GOOGLE_PAY
            - KLARNA
            - IDEAL
            - BANCONTACT
            - TWINT
            - BLIK
            - AFFIRM
            - ALMA
            - BILLIE
            - SCALAPAY
            - MULTIBANCO
            - PAYPAL
            - SEPA_DIRECT_DEBIT
            - ACH_DIRECT_DEBIT
            - STABLECOIN
            - VOLATILE_CRYPTO
          description: >-
            The method the NETWORK reported, resolved at settlement · not the
            one the payer clicked. Null until then, so absence means "not
            settled yet", never "a card". It names the payer's instrument
            (Klarna, iDEAL, Apple Pay), never the acquirer that processed it.
        declineCode:
          type: string
          nullable: true
          description: >-
            Normalized decline reason for a FAILED charge (Stripe-parity
            `decline_code`), e.g. `insufficient_funds`, `not_supported`,
            `stolen_card`, `expired_card`. Null on non-declined / non-card
            payments.
        declineCategory:
          type: string
          nullable: true
          enum:
            - SOFT
            - HARD
            - AUTHENTICATION_REQUIRED
          description: >-
            Retryability of the decline: SOFT = transient (a retry may succeed),
            HARD = terminal (the credential is unusable),
            AUTHENTICATION_REQUIRED = the card is fine but the issuer wants the
            cardholder to authenticate, which no retry can supply. Null when not
            a decline.
        declineAdvice:
          type: string
          nullable: true
          enum:
            - TRY_AGAIN_LATER
            - DO_NOT_TRY_AGAIN
            - CONFIRM_CARD_DATA
          description: >-
            The card network's own instruction on what to do next. Distinct from
            `declineCategory`, which is our reading of the reason: the same
            reason can come back TRY_AGAIN_LATER on one charge and
            DO_NOT_TRY_AGAIN on another, and only this field says which. Null
            when the network returned no advice.
        declineNetworkCode:
          type: string
          nullable: true
          description: >-
            The card network's own decline code, verbatim (e.g. Visa `1A`). Not
            normalized on purpose · it is the identifier the cardholder's bank
            recognises. Null when the network returned none.
        threeDSecureResult:
          type: string
          nullable: true
          enum:
            - AUTHENTICATED
            - ATTEMPT_ACKNOWLEDGED
            - FAILED
            - NOT_SUPPORTED
            - PROCESSING_ERROR
            - NOT_REQUESTED
          description: >-
            How the 3-D Secure step ended for this charge. A refusal that
            follows a FAILED or PROCESSING_ERROR authentication is not the same
            event as an issuer refusing the funds, and no decline code separates
            them. Null when the charge never reached an authentication step.
        displayName:
          type: string
          nullable: true
        displayDescription:
          type: string
          nullable: true
        displayImageUrl:
          type: string
          nullable: true
        priceCents:
          type: string
          nullable: true
        currency:
          type: string
          nullable: true
        tokenAmount:
          type: string
          nullable: true
        tokenFeeAmount:
          type: string
          nullable: true
        quoteFiatAmountCents:
          type: integer
          nullable: true
        quoteFiatCurrency:
          type: string
          nullable: true
        grossAmountCents:
          type: integer
          nullable: true
        platformFeeCents:
          type: integer
          nullable: true
        merchantNetCents:
          type: integer
          nullable: true
        vatAmountCents:
          type: integer
          nullable: true
          description: >-
            The VAT portion INCLUDED in `grossAmountCents`. Null whenever
            `vatRateBps` is · same three conditions.
        vatRateBps:
          type: integer
          nullable: true
          description: >-
            Applied VAT rate in basis points (2000 = 20%). Non-null only on a
            CARD charge, for a merchant Suby is the seller of record for, billed
            in the EU · null everywhere else. A crypto payment is never taxed
            whatever the merchant's mode: the payer sends an amount sized from
            the merchant's price and nothing is added on top.
        taxInclusive:
          type: boolean
          description: >-
            true = price is VAT-inclusive (gross = price); false = exclusive
            (gross = price + VAT). The behavior the charge was priced with,
            snapshotted · it records how the price was READ, so it stays
            meaningful on a charge that collected no VAT (crypto, non-MoR),
            where gross = price either way.
        fxSurchargeCents:
          type: integer
          nullable: true
          description: >-
            Currency-conversion surcharge, INCLUDED in platformFeeCents (never a
            second deduction). Billed only when the acquirer settled this charge
            in a currency it was not priced in, and only on a fee plan that
            passes it through. Null = not billed.
        internationalCardSurchargeCents:
          type: integer
          nullable: true
          description: >-
            Surcharge for a card issued outside your own region, INCLUDED in
            platformFeeCents. Resolved at capture from the card's issuing
            country. Null = not billed.
        rollingReserveCents:
          type: integer
          nullable: true
          description: >-
            Held back at capture and released on maturity · NOT a fee and NOT
            deducted from merchantNetCents. It is your own money kept aside to
            cover a refund or a chargeback, and it is the difference between
            merchantNetCents and what the balance was credited. Null when your
            account carries no reserve.
        refundedAmountCents:
          type: integer
          nullable: true
        refundedAt:
          type: string
          format: date-time
          nullable: true
        cryptoSettlementMode:
          type: string
          nullable: true
        settlementChainId:
          type: integer
          nullable: true
        settlementAsset:
          type: string
          nullable: true
        settlementRecipient:
          type: string
          nullable: true
        depositAddress:
          type: string
          nullable: true
        settlementTxHash:
          type: string
          nullable: true
          description: Ours · the settlement transfer. Null until the chain produces it.
        lifiTxHash:
          type: string
          nullable: true
          description: >-
            The bridge's transaction, on a payment we convert cross-chain. Null
            on a same-chain settlement, and until the bridge fills.
        lifiSubstatus:
          type: string
          nullable: true
          description: >-
            Where that bridge transfer currently stands, as the bridge reports
            it. Null when no bridge is involved.
        successUrl:
          type: string
          nullable: true
        cancelUrl:
          type: string
          nullable: true
        externalRef:
          type: string
          nullable: true
        paymentReceivedAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            When the payer's crypto deposit landed at the address we gave them.
            It does NOT mean the sale is confirmed: on-chain funds still have to
            clear the settlement step (the SubyPayment split, or the bridge
            transaction) before we announce anything. Null on card charges.
        paymentSettledAt:
          type: string
          format: date-time
          nullable: true
          description: When the merchant's share reached its destination on-chain.
        paymentConfirmedAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            When the sale was confirmed to both sides · the instant
            `payment.succeeded` was emitted, the access granted, the receipt
            sent and the payer shown a success screen. On a crypto payment this
            is a few seconds after `paymentReceivedAt`: the settlement step that
            proves the deposit spendable has to clear first, and if it takes
            longer than ~35s we confirm anyway rather than hold the payer on our
            settlement. Null on card charges, where the capture (COMPLETED) is
            itself the confirmation.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            When an unfinished intent stops being payable. Set for card/APM
            (bounded to the checkout-session window, else 30 min) and crypto
            (per-chain deposit window). Past this, an abandoned PENDING intent
            is swept to EXPIRED. Null only for legacy rows.
        statusTimeline:
          type: array
          description: >-
            Append-only status history · every status the payment moved through,
            oldest→newest. The last entry is where it currently stands /
            stopped. Populated on detail + list reads; empty for legacy rows
            created before the feature.
          items:
            type: object
            properties:
              status:
                type: string
                description: The status the payment transitioned into.
                enum:
                  - PENDING
                  - AUTHORIZED
                  - PENDING_3DS
                  - PENDING_REDIRECT
                  - BRIDGING
                  - RECEIVED
                  - PARTIAL
                  - COMPLETED
                  - FAILED
                  - DECLINED
                  - REFUNDED
                  - PARTIALLY_REFUNDED
                  - CANCELED
                  - EXPIRED
              at:
                type: string
                format: date-time
                description: When the transition happened.
        items:
          type: array
          description: >-
            What was bought, line by line, frozen at create time. A payment link
            charges ONE total against N products, so `productId` alone cannot
            describe the order · read this instead. Absent on payments created
            before the field existed and on charges whose caller priced no
            lines. Amounts are in the payment's `currency`; a discount applies
            to the total, so the lines do not necessarily sum to
            `grossAmountCents`.
          items:
            type: object
            properties:
              productId:
                type: string
                nullable: true
                description: >-
                  Catalogue product · null on an ad-hoc line (a price and a
                  label, no catalogue entry).
              name:
                type: string
                description: Product name as displayed at checkout, frozen at that moment.
              quantity:
                type: integer
              unitPriceCents:
                type: integer
        asset:
          type: object
          nullable: true
          description: >-
            What the PAYER sent · their chain and their token. Populated on
            crypto reads. Distinct from `settlementAsset` / `settlementChain`,
            which say where the funds are HEADED: on an account that converges
            every token to one stablecoin the two sides differ on almost every
            payment.
          properties:
            chainId:
              type: integer
            chainName:
              type: string
              nullable: true
              description: >-
                The chain's proper noun, e.g. `BNB Smart Chain`. Data, never
                localized.
            chainLogoUrl:
              type: string
              nullable: true
            symbol:
              type: string
            decimals:
              type: integer
            address:
              type: string
              description: Token contract / SPL mint, or `NATIVE`.
            logoUrl:
              type: string
              nullable: true
        settlementChain:
          type: object
          nullable: true
          description: >-
            The chain named by `settlementChainId` · where the funds converge.
            Read together with `settlementAsset`. Null on card charges.
          properties:
            chainId:
              type: integer
            name:
              type: string
              nullable: true
              description: The chain's proper noun, e.g. `Polygon`. Data, never localized.
            logoUrl:
              type: string
              nullable: true
        deposit:
          type: object
          nullable: true
          description: >-
            Where the payer's deposit stands · **`GET /v3/payments/{id}` only**,
            and only while a crypto deposit is being watched. The list read does
            not carry it: it is what you poll ONE payment with, and paging it in
            would parse three chain snapshots per row to answer a question
            nobody asks of a page.
          properties:
            expected:
              type: string
            received:
              type: string
            remaining:
              type: string
            confirmations:
              type: object
              nullable: true
              properties:
                observed:
                  type: integer
                required:
                  type: integer
                detectedAt:
                  type: string
                  format: date-time
            warnings:
              type: array
              items:
                type: object
                properties:
                  code:
                    type: string
                    enum:
                      - INSUFFICIENT_DEPOSIT_AMOUNT
                      - RBF_DETECTED
                      - NETWORK_FEE_SURCHARGE
                  message:
                    type: string
                  data:
                    type: object
                    additionalProperties: true
    PaymentInstruction:
      description: >-
        Crypto handoff (mutually exclusive with `checkout`), discriminated on
        `kind`.
      oneOf:
        - type: object
          properties:
            kind:
              type: string
              enum:
                - qr_deposit
            chainId:
              type: integer
            depositAddress:
              type: string
            expectedAmount:
              type: string
            decimals:
              type: integer
            symbol:
              type: string
            address:
              type: string
              description: Token contract / SPL mint, or `NATIVE`.
            bip21Uri:
              type: string
              nullable: true
            surchargeNote:
              type: string
              nullable: true
        - type: object
          properties:
            kind:
              type: string
              enum:
                - wallet_connect
            chainId:
              type: integer
            contractAddress:
              type: string
            calldata:
              type: string
            value:
              type: string
              nullable: true
            address:
              type: string
            blockhashExpiresAt:
              type: string
              nullable: true
            gasLimit:
              type: string
              nullable: true
            approval:
              type: object
              nullable: true
              properties:
                spender:
                  type: string
                tokenAddress:
                  type: string
                amount:
                  type: string
                calldata:
                  type: string
    Error:
      type: object
      required:
        - success
        - error
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
          description: Machine-readable error code.
          example: NOT_FOUND
        message:
          type: string
          example: Resource not found
        data:
          description: Optional error detail.
    ValidationErrorEnvelope:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            data:
              type: object
              properties:
                fieldErrors:
                  type: array
                  items:
                    type: object
                    properties:
                      field:
                        type: string
                      message:
                        type: string
    PaymentStatus:
      type: string
      enum:
        - PENDING
        - AUTHORIZED
        - PENDING_3DS
        - PENDING_REDIRECT
        - BRIDGING
        - RECEIVED
        - PARTIAL
        - COMPLETED
        - FAILED
        - DECLINED
        - REFUNDED
        - PARTIALLY_REFUNDED
        - CANCELED
        - EXPIRED
  responses:
    Unauthorized:
      description: Invalid or missing API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error: UNAUTHORIZED
            message: Invalid or missing API key
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error: NOT_FOUND
            message: Resource not found
    ValidationError:
      description: Request failed schema validation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationErrorEnvelope'
          example:
            success: false
            error: VALIDATION_ERROR
            message: Validation failed
            data:
              fieldErrors:
                - field: method
                  message: method is a required field
    RateLimited:
      description: Too many requests
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error: RATE_LIMITED
            message: Too many requests
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Suby-Api-Key
      description: Secret API key. `sk_live_…` (production) or `sk_sandbox_…` (sandbox).

````