> ## 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.

# Create a crypto charge

> The headless rail: render this flow inside your own site.

The fence on `POST /v3/payments` exists because collecting a card means PCI
scope and a tokenizer we do not hand out. Neither applies here · the payer's
wallet is the instrument, and a deposit address or a calldata payload is
public information. Nothing sensitive crosses the boundary.

Returns the charge plus the `instruction` that completes it:
`qr_deposit` gives an address and the exact amount to send (with a BIP21 URI
on Bitcoin); `wallet_connect` gives calldata for the connected wallet to
sign · ABI-encoded on EVM, a base64 transaction on Solana.

**Poll `GET /v3/payments/{id}`** for the outcome. Its `deposit` envelope
carries `expected` / `received` / `remaining` and a `confirmations`
counter, which is what lets you show "payment detected, 1/3 confirmations"
instead of leaving the payer on a QR code.

**Where the funds end up is not decided here.** Settlement routing ·
same-chain, or bridged to your account's convergence target · is an account
setting applied after the deposit lands. A treasury decision does not belong
in a checkout call.

It does constrain ONE thing, though: an account that converges to a
stablecoin on another chain can only be paid through `qr_deposit`, because
the bridge needs a Suby-held address to move funds out of. Asking for
`wallet_connect` on such an account returns
`CRYPTO_AUTOSWAP_MODE_NOT_SUPPORTED` rather than quietly settling in the
source asset on the source chain.




## OpenAPI

````yaml /v3-beta/api-reference/openapi.yaml post /v3/crypto/charges
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/crypto/charges:
    post:
      tags:
        - Crypto
      summary: Create a crypto charge
      description: >
        The headless rail: render this flow inside your own site.


        The fence on `POST /v3/payments` exists because collecting a card means
        PCI

        scope and a tokenizer we do not hand out. Neither applies here · the
        payer's

        wallet is the instrument, and a deposit address or a calldata payload is

        public information. Nothing sensitive crosses the boundary.


        Returns the charge plus the `instruction` that completes it:

        `qr_deposit` gives an address and the exact amount to send (with a BIP21
        URI

        on Bitcoin); `wallet_connect` gives calldata for the connected wallet to

        sign · ABI-encoded on EVM, a base64 transaction on Solana.


        **Poll `GET /v3/payments/{id}`** for the outcome. Its `deposit` envelope

        carries `expected` / `received` / `remaining` and a `confirmations`

        counter, which is what lets you show "payment detected, 1/3
        confirmations"

        instead of leaving the payer on a QR code.


        **Where the funds end up is not decided here.** Settlement routing ·

        same-chain, or bridged to your account's convergence target · is an
        account

        setting applied after the deposit lands. A treasury decision does not
        belong

        in a checkout call.


        It does constrain ONE thing, though: an account that converges to a

        stablecoin on another chain can only be paid through `qr_deposit`,
        because

        the bridge needs a Suby-held address to move funds out of. Asking for

        `wallet_connect` on such an account returns

        `CRYPTO_AUTOSWAP_MODE_NOT_SUPPORTED` rather than quietly settling in the

        source asset on the source chain.
      operationId: createCryptoCharge
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCryptoChargeBody'
      responses:
        '201':
          description: Charge created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SuccessEnvelope'
                  - properties:
                      data:
                        type: object
                        properties:
                          payment:
                            $ref: '#/components/schemas/Payment'
                          instruction:
                            $ref: '#/components/schemas/PaymentInstruction'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: >-
            The account does not accept crypto or that chain/asset, the mode is
            not available on that chain (Bitcoin is deposit-only) or for the
            account's settlement routing (`CRYPTO_AUTOSWAP_MODE_NOT_SUPPORTED` ·
            use `qr_deposit`), the amount is below the chain's minimum, or the
            price feed cannot quote the pair.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
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:
    CreateCryptoChargeBody:
      type: object
      required:
        - mode
        - chainId
        - asset
        - customer
      description: >-
        Pricing: `productId` (the product's price snapshot) OR `priceCents` +
        `currency`. A customer is always required · the deposit address is
        HD-derived from one, and receipts, refunds and access all hang off it.
      properties:
        mode:
          type: string
          enum:
            - qr_deposit
            - wallet_connect
          description: >-
            `qr_deposit` returns an address the payer sends to; `wallet_connect`
            returns calldata their wallet signs.
        chainId:
          type: integer
          example: 8453
          description: From GET /v3/crypto/assets.
        asset:
          type: string
          example: USDC
          minLength: 2
          maxLength: 12
          description: >-
            The token's ticker on that chain, case-insensitive. `(chainId,
            asset)` is the public key · there is no numeric asset id on this
            surface, because ours means nothing outside our database.
        customer:
          $ref: '#/components/schemas/CustomerRef'
        productId:
          type: string
          example: pro_abc123
        priceCents:
          type: string
          pattern: ^[1-9]\\d*$
          example: '1999'
          description: Minor units as a string of digits. Requires `currency`.
        currency:
          type: string
          pattern: ^[A-Z]{3}$
        displayName:
          type: string
          maxLength: 200
        displayDescription:
          type: string
          maxLength: 500
          nullable: true
        displayImageUrl:
          type: string
          format: uri
          nullable: true
        payerAddress:
          type: string
          description: >-
            `wallet_connect` only · the connected wallet's address (Solana fee
            payer, EVM sender). Omit to charge the customer's HD-derived
            address.
        externalRef:
          type: string
          maxLength: 120
        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.
    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.
    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
    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.
    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
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Suby-Api-Key
      description: Secret API key. `sk_live_…` (production) or `sk_sandbox_…` (sandbox).

````