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

# Create a one-time payment

> Creates a payment for a **one-time product** (`frequencyInDays` is `null`).
Returns a checkout URL to redirect your customer to.

For **custom price** products (`isCustomPrice: true`), provide `priceCents` and `currency`.

> **Subscription products?** Use `POST /api/subscription/create` instead.




## OpenAPI

````yaml /v2/api-reference/openapi.yaml post /api/payment/create
openapi: 3.0.3
info:
  title: Suby.fi Merchant API
  description: >
    API for merchants to integrate Suby.fi payments into their applications.


    ## Quick Start


    1. **Create a product** — define what you sell (one-time or recurring)

    2. **Create a payment or subscription** — generate a checkout link for your
    customer

    3. **Listen for webhooks** — get notified when payments succeed, fail, or
    are refunded


    ## Authentication


    Include your API key in the `X-Suby-Api-Key` header:

    ```

    X-Suby-Api-Key: your_api_key_here

    ```


    Generate your API key from your [merchant dashboard](https://app.suby.fi).


    ## Pricing


    All prices are in **cents** as strings. For example:

    - `"999"` = 9.99 USD/EUR

    - `"1500"` = 15.00 USD/EUR


    ### Custom Pricing


    Products can be created with `isCustomPrice: true` to allow dynamic amounts.

    Instead of a fixed price, the amount is set per-payment via `priceCents`
    when creating a payment or subscription.


    ## Customer Identity


    Every customer has a stable `customerId` (returned by the `GET
    /api/customer` endpoints and on every

    payment/subscription response and webhook). It never changes — **even if the
    customer later changes their

    email** — so it is the reliable key for long-term account history.


    On `POST /api/payment/create` and `POST /api/subscription/create` you can
    identify the customer two ways:


    - **`customerId`** (recommended for returning customers) — links the payment
    to an existing customer by id,
      regardless of their current email. Takes precedence over `customerEmail`. Returns `404` if the id is unknown.
    - **`customerEmail`** — a user account is created (or reused by email) and
    linked to the payment. Use this the
      first time you see a customer; store the `customerId` from the response for next time.
    - **Neither** — the payment is created without a customer. The hosted
    checkout page (`paymentUrl`) prompts the
      buyer for their email at pay time and links the customer then. Webhooks fired **before** that (e.g.
      `CHECKOUT_INITIATED`) have `customerId: null` / `customerEmail: null`; webhooks after a successful payment
      (`CHECKOUT_SUCCESS`, `PAYMENT_SUCCESS`, …) always include both.

    `customerFirstName` / `customerLastName` are optional; they only backfill a
    display name when the customer has

    none, and are ignored when neither `customerId` nor `customerEmail` is
    provided.


    ## Subscription Identity


    A subscription is owned by a **`(customer, product, externalRef)`** triple —
    **not** by customer + product

    alone. This lets a single customer (same `customerId` / email) hold
    **several concurrent subscriptions to the

    same product**, one per distinct `externalRef` (e.g. one per end-user
    account or wallet on your side).


    - **Same `externalRef`** for the same customer + product → the existing
    subscription is reused and **renewed**.

    - **Different `externalRef`** → a **separate** subscription is created. A
    payment made under one `externalRef`
      never extends or reuses a subscription created under another.
    - **No `externalRef`** → all such checkouts for the same customer + product
    share a single subscription (the
      "no external reference" bucket).

    The `externalRef` is set on `POST /api/subscription/create` and is returned
    on the subscription object and on

    every subscription webhook (`data.context.externalRef`).


    **One-time payments** (`POST /api/payment/create`) never create or extend a
    subscription: each call is an

    independent payment with its own `paymentId`, regardless of `externalRef`.


    ## Idempotent Refunds


    `POST /api/refund/{paymentId}` accepts an optional **`Idempotency-Key`**
    header (any unique string you

    generate per refund). If a request times out, retry it with the **same
    key**: the original result is replayed

    (response header `Idempotency-Replayed: true`) instead of issuing the refund
    again. This is essential for

    **partial refunds**, which are otherwise legitimately repeatable. Reusing a
    key with a different request body

    returns `409`. The header is optional; without it, a full refund is still
    naturally idempotent (a second call

    returns a "not refundable" error) but a partial refund is not.


    ## Sandbox Mode


    Create products with `isSandbox: true` to test payment flows without real
    transactions.


    - **Card payments** use test card numbers (see sandbox endpoints in the
    dashboard checkout)

    - **Crypto payments** only support **Base Testnet** (chain ID `84532`). If
    no chains/assets are specified, Base Testnet and its assets are
    auto-selected.

    - **Refunds** on sandbox card payments skip the payment provider and
    directly mark the payment as `REFUNDED`

    - Sandbox payments do **not** create merchant payouts or rolling reserves

    - Webhooks (`CHECKOUT_SUCCESS`, `PAYMENT_SUCCESS`, `PAYMENT_REFUNDED`) are
    still sent normally


    The `isSandbox` field is returned on both product and payment responses.
  version: 2.6.0
  contact:
    email: dev@suby.fi
    url: https://suby.fi
servers:
  - url: https://api.suby.fi
    description: Production server
security:
  - ApiKeyAuth: []
tags:
  - name: Products
    description: >
      Create and manage products. A product defines **what** you sell:

      - **One-time product** — `frequencyInDays` is `null`. Customers pay once.

      - **Subscription product** — `frequencyInDays` is set (e.g. `30` for
      monthly). Customers are billed recurrently.
  - name: Payments - Checkout
    description: |
      Create and track **one-time payments**.
      Use these endpoints for products where `frequencyInDays` is `null`.
  - name: Subscriptions - Checkout
    description: >
      Create, track, and cancel **recurring subscriptions**.

      Use these endpoints for products where `frequencyInDays` is set.


      A subscription is owned by a **`(customer, product, externalRef)`**
      triple: the same customer can hold

      several concurrent subscriptions to the same product by passing a distinct
      `externalRef` on each

      `POST /api/subscription/create`. See **Subscription Identity** in the
      introduction for the full rules.
  - name: Refunds
    description: Refund card (fiat) payments — full or partial
  - name: Discounts
    description: >-
      Create reusable discount codes (percentage or fixed-amount) across one or
      more products
  - name: Customers
    description: Customer information and payment history
  - name: Webhooks
    description: >
      Webhook event format and verification.

      Two event categories are sent:

      - **Payment events** — `CHECKOUT_INITIATED`, `CHECKOUT_SUCCESS`,
      `CHECKOUT_FAILED`, `PAYMENT_SUCCESS`, `PAYMENT_FAILED`,
      `PARTIAL_REFUNDED`, `PAYMENT_REFUNDED`, `PAYMENT_CHARGEBACK`,
      `PAYMENT_SETTLED`

      - **Subscription events** — `SUBSCRIPTION_CREATED`,
      `SUBSCRIPTION_RENEWED`, `SUBSCRIPTION_PAST_DUE`, `SUBSCRIPTION_EXPIRED`


      All webhooks include these headers:

      - `X-Webhook-Event` — the event type

      - `X-Webhook-Timestamp` — Unix timestamp

      - `X-Webhook-Signature` — `v1={hmac_hex}`
paths:
  /api/payment/create:
    post:
      tags:
        - Payments - Checkout
      summary: Create a one-time payment
      description: >
        Creates a payment for a **one-time product** (`frequencyInDays` is
        `null`).

        Returns a checkout URL to redirect your customer to.


        For **custom price** products (`isCustomPrice: true`), provide
        `priceCents` and `currency`.


        > **Subscription products?** Use `POST /api/subscription/create`
        instead.
      operationId: createPayment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - productId
              properties:
                productId:
                  type: string
                  description: ID of a one-time product
                  example: pro_abc123
                customerId:
                  type: string
                  maxLength: 255
                  description: >
                    Optional. Links the payment to an existing customer by their
                    stable id (as returned by

                    `GET /api/customer` and on payment responses/webhooks).
                    Keeps the same customer linked even if

                    their email has changed. Takes precedence over
                    `customerEmail`. Returns `404` if unknown.
                  example: usr_abc123
                customerEmail:
                  type: string
                  format: email
                  description: >
                    Optional. If provided (and no `customerId` is given), a user
                    account is created (or reused by

                    email) immediately and linked to the payment. If both are
                    omitted, the payment is created without

                    a customer and the email is collected on the hosted checkout
                    page.
                  example: customer@example.com
                customerFirstName:
                  type: string
                  maxLength: 100
                  description: >-
                    Optional. Customer first name. Backfills the display name
                    when the customer has none. Ignored when neither
                    `customerId` nor `customerEmail` is provided.
                  example: John
                customerLastName:
                  type: string
                  maxLength: 100
                  description: >-
                    Optional. Customer last name. Backfills the display name
                    when the customer has none. Ignored when neither
                    `customerId` nor `customerEmail` is provided.
                  example: Doe
                priceCents:
                  type: string
                  description: >
                    Price in cents as a string.

                    **Required** when the product has `isCustomPrice: true`.
                    **Must NOT be provided** for fixed-price products.
                  example: '2500'
                currency:
                  type: string
                  enum:
                    - USD
                    - EUR
                  description: >
                    Currency for the price. **Required** when `priceCents` is
                    provided, ignored otherwise.
                  example: USD
                discountCode:
                  type: string
                  maxLength: 50
                  description: >
                    Optional. A discount code (created via `POST
                    /api/discount/create`) to pre-apply to this checkout.

                    The discount is applied to the amount the customer pays.
                    Ignored if the code is invalid,

                    expired, exhausted, or not attached to this product.
                  example: WELCOME10
                externalRef:
                  type: string
                  maxLength: 255
                  description: >
                    Your internal reference (order ID, invoice number, etc.).
                    One-time payments are always

                    independent — each `POST /api/payment/create` yields its own
                    `paymentId` and never creates or

                    extends a subscription, regardless of `externalRef`.
                  example: order_789
                metadata:
                  type: object
                  description: Custom key-value pairs. Returned in webhooks.
                  additionalProperties: true
                customFields:
                  type: array
                  maxItems: 10
                  items:
                    $ref: '#/components/schemas/CustomField'
                  description: >
                    Extra fields shown on the checkout page to collect
                    information from the customer

                    (e.g. Discord username, referral source, terms acceptance).

                    Customer responses are returned in the
                    `context.customFieldsResponse` object of

                    every payment webhook.


                    Maximum **10 fields** per payment. For subscriptions, fields
                    are collected on the

                    initial checkout only — renewals do not re-prompt the
                    customer, and their webhooks

                    will have `customFields` and `customFieldsResponse` set to
                    `null`.
                successUrl:
                  type: string
                  format: uri
                  description: Redirect URL after successful payment
                  example: https://your-app.com/success
                cancelUrl:
                  type: string
                  format: uri
                  description: Redirect URL if customer cancels
                  example: https://your-app.com/cancel
      responses:
        '200':
          description: Payment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      paymentId:
                        type: string
                        example: pay_abc123
                      paymentUrl:
                        type: string
                        format: uri
                        description: Checkout URL — redirect your customer here
                        example: https://checkout.suby.fi/pay/pay_abc123
                      metadata:
                        type: object
                        nullable: true
                      customFields:
                        type: array
                        nullable: true
                        items:
                          $ref: '#/components/schemas/CustomField'
                        description: >-
                          Echo of the custom field definitions stored on the
                          payment (null if none were provided)
        '400':
          description: >-
            Validation error (e.g. product is a subscription, use
            /api/subscription/create)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/UnauthorizedError'
        '404':
          $ref: '#/components/responses/NotFoundError'
components:
  schemas:
    CustomField:
      type: object
      description: >
        A custom field definition attached to a one-time payment.

        The merchant defines these at payment creation; the customer fills them
        on the checkout page.

        Customer responses are returned in the webhook payload as
        `context.customFieldsResponse`

        (a `{ key: value }` map). Checkbox responses are the literal strings
        `"true"` or `"false"`.
      required:
        - key
        - label
        - type
      properties:
        key:
          type: string
          maxLength: 50
          pattern: ^[a-z0-9_]+$
          description: >
            Unique identifier for the field. Must be unique across the
            `customFields` array on the same payment.

            Lowercase alphanumeric + underscores. Used as the key in
            `customFieldsResponse`.
          example: discord_username
        label:
          type: string
          maxLength: 100
          description: Human-readable label shown on the checkout page.
          example: Discord username
        type:
          type: string
          enum:
            - input
            - select
            - checkbox
          description: >
            - `input` — free-text field (optionally validated via
            `validation.regex`)

            - `select` — dropdown; `options` is required

            - `checkbox` — boolean; stored as `"true"` or `"false"` in the
            response
        required:
          type: boolean
          default: false
          description: >
            When `true`, the customer must fill the field (`input`/`select`) or
            tick it (`checkbox`)

            before submitting the payment. Defaults to `false`.
        placeholder:
          type: string
          maxLength: 200
          description: >
            Optional placeholder shown inside the field (`input`) or as the
            unselected state of the dropdown (`select`).

            Ignored for `type: checkbox`. When omitted, no placeholder is
            rendered.
          example: e.g. test#1234
        options:
          type: array
          description: >-
            **Required for `type: select`.** The list of choices offered to the
            customer.
          items:
            type: object
            required:
              - value
              - label
            properties:
              value:
                type: string
                maxLength: 100
                example: twitter
              label:
                type: string
                maxLength: 100
                example: Twitter / X
        validation:
          type: object
          description: >
            **`type: input` only.** Regex validation applied to the customer's
            input on the checkout page

            (**client-side only**). The server does not re-validate the value
            against `regex`, so this

            is a UX helper — re-check the submitted value in your webhook
            handler if it is

            security-critical.
          properties:
            regex:
              type: string
              maxLength: 500
              description: >-
                Regular expression the customer's input must match (enforced in
                the browser).
              example: ^.{2,32}$
            errorMessage:
              type: string
              maxLength: 200
              description: >-
                Error shown to the customer when the input does not match
                `regex`.
              example: Please enter a valid Discord username
    Error:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          properties:
            code:
              type: string
              example: PRODUCT_NOT_FOUND
            message:
              type: string
              example: Product not found
  responses:
    UnauthorizedError:
      description: Invalid or missing API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: UNAUTHORIZED
              message: Invalid or missing API key
    NotFoundError:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: NOT_FOUND
              message: Resource not found
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Suby-Api-Key
      description: API key authentication

````