openapi: 3.1.0

info:
  title: Avvio Checkout
  version: "2026-09-28.2"
  summary: Accept card, bank and crypto payments on your website over one API.
  description: |
    Take payments on a hosted checkout page. You create a checkout link for a
    product or an amount, send your buyer to it, and learn from a webhook when
    they have paid.

    It uses the same API key, base URL and errors as the Payouts API. With a
    test key, `POST …/links/{linkId}/payments/simulate` pays a link, and the
    last two digits of the total in minor units pick the outcome (paid,
    declined, refunded or charged back). `GET …/sandbox/scenarios` lists them.
    Nothing is charged, and events arrive signed with `livemode: false`.

    Creating a product or a link requires an `Idempotency-Key`. Retry a
    timed-out create with the same key and you get the original link back.

    `checkout_payment.*` events arrive on the webhook endpoints you register
    through the Payouts API, signed the same way.
    [Webhook events](https://docs.avvio.xyz/coverage/webhook-events/) shows
    each payload.

    Generate a client from this file
    (`openapi-generator-cli generate -i partner-checkout.openapi.yaml -g java -o ./avvio-checkout`),
    or use `@avvio/payments` 0.6.0 or later for Node.

servers:
  - url: https://api.avvio.xyz/business/api/v1
    description: Sandbox with a test key, live with a live key

security:
  - ApiKeyAuth: []

tags:
  - name: Products
    description: "What you sell: a name, a price and a currency."
  - name: Checkout links
    description: "A hosted page that takes payment for a product or an amount."
  - name: "Payments & refunds"
    description: "What a link collected, and giving it back."
  - name: Sandbox
    description: "Test-key only: simulate payments and their outcomes."

x-tagGroups:
  - name: Checkout API
    tags: [Products, Checkout links, "Payments & refunds", Sandbox]


paths:
  # ────────────────────────────────────────────────────────── Checkout ──
  # Money arriving: a link a buyer pays on a hosted page. The field names
  # (successUrl, clientReferenceId, metadata, publish-on-create) follow the
  # conventions most hosted-checkout APIs share, so the shape is familiar.
  /checkout/organizations/{orgId}/products:
    get:
      tags: [Products]
      operationId: listCheckoutProducts
      summary: List products
      description: |
        Lists your catalog, every product, active first. The image is never on the list (fifty
        products must not weigh 35MB); read one product for it. Read-only keys
        may call this.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "200":
          description: The catalog, as a bare array.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CheckoutProduct" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Products]
      operationId: createCheckoutProduct
      summary: Create a product
      description: |
        Creates a product: a name and one fixed price in one currency. A link made from it copies
        the name and price at that moment; changing the product later changes
        nothing about links already made from it (the description and image
        are read live, so a typo fixed here is fixed on every live page).

        `(name)` is unique among your active products: a second `409`s. Archive
        the old one to reuse the name. `Idempotency-Key` is required on an
        API key.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCheckoutProductRequest" }
      responses:
        "201":
          description: The product.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutProduct" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "409":
          description: A product with that name already exists, or the `Idempotency-Key` was reused with a different body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                nameTaken:
                  value:
                    type: CONFLICT
                    status: 409
                    detail: You already have a product with that name.
                    requestId: req-af1
                    message: You already have a product with that name.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/products/{productId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: productId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Products]
      operationId: getCheckoutProduct
      summary: Get a product
      description: |
        One product, with its image.
      responses:
        "200":
          description: The product. `image` is the stored `data:image/...` URL or null.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/CheckoutProduct"
                  - type: object
                    required: [image]
                    properties:
                      image:
                        type: [string, "null"]
                        description: The stored image data URL. Only on this single read, never on the list.
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Products]
      operationId: updateCheckoutProduct
      summary: Update a product
      description: |
        Edit a product.

        Every field optional. `archived: true` hides it from the picker and
        refuses new links from it; existing links are untouched either way.
        `Idempotency-Key` is required on an API key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateCheckoutProductRequest" }
      responses:
        "200":
          description: The product after the edit.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutProduct" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Another active product already has that name, or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                nameTaken:
                  value:
                    type: CONFLICT
                    status: 409
                    detail: You already have a product with that name.
                    requestId: req-ag2
                    message: You already have a product with that name.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Products]
      operationId: deleteCheckoutProduct
      summary: Delete a product
      description: |
        Delete a product no link uses.

        Only while no link references it (`linkCount: 0`); otherwise a `409`
        telling you to archive instead. The record of what a buyer was sold
        is not deletable. `Idempotency-Key` is required on an API key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success: { type: boolean, const: true }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Links reference this product (archive it), or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                hasLinks:
                  value:
                    type: CONFLICT
                    status: 409
                    detail: This product has checkout links. Archive it instead.
                    requestId: req-ah3
                    message: This product has checkout links. Archive it instead.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/links:
    post:
      tags: [Checkout links]
      operationId: createCheckoutLink
      summary: Create a checkout link
      description: |
        Creates a link a buyer can pay. Send either a
        `productId` or `currency` + `items`. Every link carries `shareUrl`, the
        page to send your buyer to; with `publish: true` it is live on return.

        Without `publish` the link is a `draft`: not publicly readable, editable
        with `PATCH`, deletable. `POST /links/{linkId}/publish` makes it live.

        `methods` omitted means `[{ "kind": "card" }]`, the hosted card page
        (card, wallets and PayPal). `methods: []` is a link nothing can pay.
        Bank and crypto rails need the account or address to pay into.

        **When publish is refused inside a `publish: true` create, the call
        still answers `201`** with the kept draft: the normal link body with
        `status: "draft"` and a `publishError: { status, type, message }`
        saying why (a business not yet set up for cards, a currency the
        processor cannot price). Branch on `status !== "sent"`, then read
        `publishError`. Fix the cause and `POST /links/{linkId}/publish`, or
        `DELETE` the draft. It answers `201` rather than throwing because a
        stored idempotent response is only kept for a success: an error would
        release the key, and a client's automatic retry would create a second
        draft.

        The exception is the card processor failing to answer at all (a
        timeout or a 5xx while the hosted card page is minted). That answers
        `500 INTERNAL`, **and the draft is kept**. Do not retry the create:
        list your drafts (`GET /links?status=draft`) and publish or delete the
        one already made.

        `expiresAt` gives the link a deadline. From
        that instant the page answers `410` and the link reads `expired`;
        the merchant's own booking or order logic decides what a payment that
        lands after it means. Omitted, the link runs until paused.

        `Idempotency-Key` is **required** on an API key (`400
        IDEMPOTENCY_KEY_REQUIRED` without it): a retry under the same key
        returns the same link instead of a second one.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCheckoutLinkRequest" }
      responses:
        "201":
          description: "The link. `status` is `sent` after a successful `publish: true`; `draft` otherwise, with `publishError` set when a publish was refused."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutLinkCreated" }
              examples:
                published:
                  summary: publish succeeded
                  value:
                    id: 4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10
                    slug: 7e2a9c4b1d0f
                    status: sent
                    shareUrl: https://business.avvio.xyz/i/7e2a9c4b1d0f
                    fromName: Northstar Consulting
                    currency: USD
                    total: "150.00"
                    clientReferenceId: order_1042
                    metadata: { orderId: "1042" }
                    product: { id: 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d, name: Consulting (60 min) }
                    received: null
                    successUrl: https://example.com/thanks
                    cancelUrl: https://example.com/pricing
                    createdAt: "2026-09-12T09:58:00.000Z"
                    expiresAt: null
                    source: api
                publishRefused:
                  summary: publish refused, draft kept
                  value:
                    id: 4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10
                    slug: 7e2a9c4b1d0f
                    status: draft
                    shareUrl: https://business.avvio.xyz/i/7e2a9c4b1d0f
                    fromName: Northstar Consulting
                    currency: USD
                    total: "150.00"
                    clientReferenceId: order_1042
                    metadata: { orderId: "1042" }
                    product: { id: 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d, name: Consulting (60 min) }
                    received: null
                    successUrl: https://example.com/thanks
                    cancelUrl: https://example.com/pricing
                    createdAt: "2026-09-12T09:58:00.000Z"
                    expiresAt: null
                    source: api
                    publishError:
                      status: 400
                      type: BAD_REQUEST
                      message: This business is not set up to accept cards yet. Finish card onboarding, then publish the link.
        "400":
          description: |
            `VALIDATION_ERROR` (a field is malformed, or a `token`, `chain` or
            `currency` we do not support; `errors` names each),
            `BAD_REQUEST` (both `productId` and `items`; an archived product; a
            currency the card processor cannot price, such as `CNY`; a bank
            rail with no account), or `IDEMPOTENCY_KEY_REQUIRED` /
            `IDEMPOTENCY_KEY_INVALID` (no header, or a malformed one).
            A refused publish is not an error here: the draft comes back `201`
            with `publishError`, whose `type` is `LINK_EXPIRED` when the
            draft's `expiresAt` has already passed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                bothSources:
                  value:
                    type: BAD_REQUEST
                    status: 400
                    detail: Send either productId or currency and items, never both.
                    requestId: req-apb
                    message: Send either productId or currency and items, never both.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404":
          description: "`NOT_FOUND`: the `productId` is not a product in this organization."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                noProduct:
                  value:
                    type: NOT_FOUND
                    status: 404
                    detail: Product not found
                    requestId: req-apc
                    message: Product not found
                    statusCode: 404
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500":
          description: |
            `INTERNAL`: with `publish: true`, the card processor did not answer
            while the card page was being set up. The draft **was kept**. Do
            not retry the create; find the draft and publish or delete it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                processorDown:
                  value:
                    type: INTERNAL
                    status: 500
                    detail: Internal server error
                    resolution: Nothing was recorded under this Idempotency-Key, so retrying with the same key is safe. If it persists, send us the requestId.
                    requestId: req-apd
                    message: Internal server error
                    statusCode: 500
    get:
      tags: [Checkout links]
      operationId: listCheckoutLinks
      summary: List checkout links
      description: |
        Your links, newest first.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: status
          in: query
          description: "`draft` (unpublished), `sent` (live), `cancelled` (paused) or `expired` (past its `expiresAt`)."
          schema:
            type: string
            enum: [draft, sent, cancelled, expired]
            examples: [sent]
        - name: source
          in: query
          description: "`api` for links your server created with an API key (one per order, typically), `dashboard` for links a person made by hand. Omit for both."
          schema:
            type: string
            enum: [api, dashboard]
            examples: [api]
        - name: limit
          in: query
          description: 1 to 100. Defaults to 20. Above 100 is clamped to 100; zero, negative or not a number falls back to 20 (never a 400).
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: The `nextCursor` from the previous page, verbatim. Opaque.
          schema: { type: string }
      responses:
        "200":
          description: A page. `nextCursor` is absent on the last one.
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CheckoutLink" }
                  nextCursor: { type: string, example: 5a2d3b0f-9e8c-4d4c-8b3f-7c6e5d4b3a21 }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/links/{linkId}:
    parameters:
      - $ref: "#/components/parameters/OrgId"
      - name: linkId
        in: path
        required: true
        description: The link's `id` (not its `slug`).
        schema: { type: string }
    get:
      tags: [Checkout links]
      operationId: getCheckoutLink
      summary: Get a checkout link
      description: |
        One link, with what it has received.
      responses:
        "200":
          description: The link.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutLink" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Checkout links]
      operationId: updateCheckoutLink
      summary: Update a checkout link
      description: |
        Edit a draft, or move a live link's deadline.

        **Drafts only**, with one exception. A published link is a URL a buyer
        may already be looking at, and repricing it under them is a money bug:
        editing one is a `400`. Pause it and create a new one. `metadata` is
        replaced whole. Sending `fromName: ""` falls back to your
        organization's name.

        The exception is `expiresAt`. A `PATCH` carrying `expiresAt` and
        nothing else is accepted on a live (`sent`) link, because moving a
        deadline changes when the page stops answering, not what it charges.
        `null` clears it. An `expired` link cannot be revived this way; create
        a new one. `Idempotency-Key` is required on an API key.

        `taxRate` and `taxInclusive` only take effect when `items` is in the
        same request; sent alone they are ignored and the call still answers
        `200`.

        An unknown `linkId` answers `404 NOT_FOUND`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateCheckoutLinkRequest" }
      responses:
        "200":
          description: The link after the edit (a draft, or a live link whose `expiresAt` moved).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutLink" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/LinkConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Checkout links]
      operationId: deleteCheckoutLink
      summary: Delete a checkout link
      description: |
        Delete a draft.

        **Drafts only.** A link that has been live may have been paid, and its
        payments live under it; deleting it would delete the record of money
        that arrived. Pause a published link instead. `Idempotency-Key` is
        required on an API key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success: { type: boolean, const: true }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/links/{linkId}/publish:
    post:
      tags: [Checkout links]
      operationId: publishCheckoutLink
      summary: Publish a checkout link
      description: |
        Make a draft live.

        `draft` to `sent`. From here `shareUrl` serves the page. For a link
        offering `card` this also mints the hosted card page behind it, so the
        two cannot come apart: a page that says "Card" with nothing behind it
        is a buyer clicking pay and landing nowhere.

        Publishing an already-live link is a no-op that returns it. A paused
        or expired link cannot be published again, and a draft whose
        `expiresAt` has already passed is refused with `400 LINK_EXPIRED`
        before any processor call. `Idempotency-Key` is required on an API
        key.

        If the card processor refuses, the answer is `503` with its reason and
        the link is unchanged. If it does not answer at all (a timeout or a
        5xx) the answer is `500 INTERNAL`, and the card page may have been set
        up: retrying straight away answers `409` ("being changed") until the
        attempt clears, after which publish again.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: linkId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: "The live link, `status: sent`, with `shareUrl`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutLink" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `CONFLICT`: another publish or edit of this link is in flight
            (retry after a moment), or the draft was repriced after its card
            page was set up ("Delete this draft and create a new one").
            Or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`,
            `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                beingChanged:
                  value:
                    type: CONFLICT
                    status: 409
                    detail: This checkout link is being changed. Try again in a moment.
                    requestId: req-aj5
                    message: This checkout link is being changed. Try again in a moment.
                    statusCode: 409
                repriced:
                  value:
                    type: CONFLICT
                    status: 409
                    detail: This link's card checkout was set up at 150.00 USD and the link now totals 175.00 USD. Delete this draft and create a new one.
                    requestId: req-aj6
                    message: This link's card checkout was set up at 150.00 USD and the link now totals 175.00 USD. Delete this draft and create a new one.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500":
          description: |
            `INTERNAL`: the card processor did not answer while the card page
            was being set up. The link is still a draft, but the page may
            exist; a publish retried at once answers `409` until the attempt
            clears.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                processorDown:
                  value:
                    type: INTERNAL
                    status: 500
                    detail: Internal server error
                    resolution: Nothing was recorded under this Idempotency-Key, so retrying with the same key is safe. If it persists, send us the requestId.
                    requestId: req-ak7
                    message: Internal server error
                    statusCode: 500
        "503":
          description: |
            `INTERNAL` (status 503): card payments are not set up on this
            environment, or the card processor refused to set up this link
            (its reason is in `detail`). The link is unchanged and stays a
            publishable draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                refused:
                  value:
                    type: INTERNAL
                    status: 503
                    detail: 'Our card processor refused to set up this link: the statement descriptor is too long. The link is unchanged — nothing was published.'
                    resolution: Nothing was recorded under this Idempotency-Key, so retrying with the same key is safe. If it persists, send us the requestId.
                    requestId: req-ak6
                    message: 'Our card processor refused to set up this link: the statement descriptor is too long. The link is unchanged — nothing was published.'
                    statusCode: 503

  /checkout/organizations/{orgId}/links/{linkId}/pause:
    post:
      tags: [Checkout links]
      operationId: pauseCheckoutLink
      summary: Pause a checkout link
      description: |
        Stop a live link taking payments.

        Takes a live link (or a draft) off sale. `status` becomes
        `cancelled`; the public page answers `410` and every rail behind it
        stops. **Permanent in this version**: a paused link cannot be
        republished. Create a new link to sell again. Payments already
        recorded are untouched. Pausing an `expired` link changes nothing and
        returns it still `expired`. `Idempotency-Key` is required on an API
        key.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: linkId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: "The link, `status: cancelled` (or still `expired`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutLink" }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/links/{linkId}/payments:
    get:
      tags: ["Payments & refunds"]
      operationId: listCheckoutPayments
      summary: List payments on a link
      description: |
        Lists the payments made on one link, newest first and cursor-paged. This is the authoritative read: a success
        page must confirm here (or from the `checkout_payment.paid` webhook),
        never from the redirect alone.

        Amounts here are **base units** with `decimals` beside them
        (`"15000"` at `decimals: 2` is 150.00), the dashboard's convention. The
        webhook body for the same payment uses decimal strings (`"150.00"`).

        Refunding is `POST /checkout/organizations/{orgId}/payments/{paymentId}/refund`,
        on a key with the `refunds` scope, or from the dashboard by an owner or
        admin. Each refund is listed under `refunds` on the payment.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: linkId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          description: 1 to 100. Defaults to 20. Above 100 is clamped to 100; zero, negative or not a number falls back to 20 (never a 400).
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: The `nextCursor` from the previous page, verbatim.
          schema: { type: string }
      responses:
        "200":
          description: A page. `nextCursor` is absent on the last one.
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CheckoutPayment" }
                  nextCursor: { type: string, example: cmf9x1b2c0004q8b7j7k9l1mn }
        "400": { $ref: "#/components/responses/CheckoutBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ─────────────────────────────────────────────────────────── Sandbox ──
  # Test keys only. A simulated acceptor stands in for the card processor, so
  # nothing is charged and no card is involved. Tagged `Sandbox` rather than
  # `Checkout` because "when can I call this" is the distinction that changes
  # what a partner does.
  /checkout/organizations/{orgId}/links/{linkId}/payments/simulate:
    post:
      tags: [Sandbox]
      operationId: simulateCheckoutPayment
      summary: Simulate a payment
      description: |
        Pay a sandbox link.

        A buyer pays this link, in the sandbox. The one act the simulator
        cannot derive: everything afterwards follows from the link's amount and
        elapsed time, but whether somebody paid at all is a decision.

        **The amount picks the outcome.** The last two digits of the link's
        total in minor units (the cents, on a two-decimal currency) select
        what happens, the same way the last four digits of a recipient
        account do on the payouts side. A total ending `.04` is paid and then
        charged back a minute later, which is the case most integrations get
        wrong and the one thing no card processor's own sandbox can rehearse.
        Read `GET /checkout/organizations/{orgId}/sandbox/scenarios` for the
        table rather than hardcoding it.

        Returns the payment as it stands the instant it was created. An
        ordinary amount is already `paid`; `.02` comes back `pending` and
        settles over the next twenty seconds. Poll
        `GET /links/{linkId}/payments`, or subscribe to `checkout_payment.*`. Sandbox deliveries are signed identically to live ones and carry
        `livemode: false`.

        `Idempotency-Key` is **required**, as it is on every other write here.
        Retrying with the same key returns the original payment rather than
        inventing a second buyer. Pass `reference` to choose the deduplication
        key yourself.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: linkId
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reference:
                  type: string
                  maxLength: 200
                  pattern: '^[A-Za-z0-9_.:-]+$'
                  description: |
                    Your own reference, and the deduplication key within this
                    link. The same reference twice is one payment. Generated
                    when omitted.
                  examples: ["order_1042"]
                clientReferenceId:
                  type: string
                  maxLength: 200
                  pattern: '^[A-Za-z0-9_-]+$'
                  description: |
                    Echoed on the payment event exactly as a real card visit's
                    `?client_reference_id=` would be, so a fulfillment handler
                    can be tested on its real join key.
                  examples: ["order_1042"]
              examples:
                - { reference: order_1042, clientReferenceId: order_1042 }
        # There is no `amount` and no `scenario` field, on purpose. The amount
        # is the trigger, re-derived from the link on every read, so a value
        # passed here would let a payment settle as one thing and report another.
      responses:
        "200":
          description: The payment, as it stands at this instant.
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id: { type: string }
                  status: { $ref: "#/components/schemas/CheckoutPaymentStatus" }
              examples:
                paid:
                  summary: an ordinary amount
                  value: { id: cmf9x1b2c0003q8b7h6j8k0lm, status: paid }
                pending:
                  summary: a total ending .02, settling over twenty seconds
                  value: { id: cmf9x1b2c0003q8b7h6j8k0lm, status: pending }
        "400": { $ref: "#/components/responses/SandboxBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/payments/{paymentId}/refund:
    post:
      tags: ["Payments & refunds"]
      operationId: refundCheckoutPayment
      summary: Refund a payment
      description: |
        Refund a card payment, whole or in part.

        **Moves money** out of your balance and back to the buyer's card. Omit
        `amount` to refund everything still refundable; pass a decimal string
        in the payment's currency to refund part of it. `paymentId` is the
        payment's `id` from `GET /links/{linkId}/payments` (the acquirer's
        `externalId` is accepted too).

        **The key needs the `refunds` scope.** It is a consent an owner or
        admin grants when the key is issued (`scopes: ["write", "refunds"]`);
        a key without it, including one issued before scopes existed, gets
        `403 INSUFFICIENT_SCOPE` and nothing moves.

        `Idempotency-Key` is required, and the acquirer's refund endpoint has
        no idempotency of its own, so the key is what makes a retry safe: reuse
        it. A byte-identical refund of the same payment under a *different*
        key within fifteen minutes is refused with `DUPLICATE_REQUEST_DETECTED`;
        a genuine second refund of the same amount sends `X-Allow-Duplicate:
        true`. One refund is in flight per payment at a time: a second one
        while the first is still being confirmed is `409 REFUND_IN_PROGRESS`;
        read the payment and try again once `refundedBase` has moved.

        **When the outcome is unknown** (the card processor did not answer
        definitively) the answer is `500 PAYOUT_OUTCOME_UNKNOWN`. The refund
        may have gone through. **Do not resend it, and never under a new
        `Idempotency-Key`**: read the payment first. `refundedBase` moves, and
        `checkout_payment.refunded` or `.partially_refunded` follows, if it
        did. A replay of the same key answers `409 PAYOUT_OUTCOME_UNKNOWN`
        until we resolve it. (The shared message speaks of a payout and
        `GET /orders`; for a refund, read the payment instead.)

        A full refund publishes `checkout_payment.refunded`; a partial one
        publishes `checkout_payment.partially_refunded` (opt-in, like the rest
        of the family). Both carry the refund just made in `refund`. Every
        payment read lists its refunds under `refunds`.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: paymentId
          in: path
          required: true
          description: The payment's `id`, or the acquirer's `externalId`.
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/AllowDuplicate"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundCheckoutPaymentRequest" }
      responses:
        "200":
          description: The payment after the refund, with the refund that was just made.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RefundCheckoutPaymentResponse" }
        "400":
          description: |
            `VALIDATION_ERROR` (a malformed amount, more decimal places than
            the currency has, an unknown reason, a note over 255 characters),
            `REFUND_EXCEEDS_REMAINING` (more than `amountBase - refundedBase`),
            `REFUND_NOT_ALLOWED` (the payment is not `paid`: already refunded
            in full, charged back, failed or still pending), `REFUND_DISPUTED`
            (a dispute is open; it decides where the money goes),
            `PROVIDER_REJECTED` (the card processor refused, with its words),
            or `IDEMPOTENCY_KEY_REQUIRED` / `IDEMPOTENCY_KEY_INVALID`.
            **Nothing moved** on any of them.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                exceeds:
                  value:
                    type: REFUND_EXCEEDS_REMAINING
                    status: 400
                    detail: At most 12.50 USD is left to refund on this payment.
                    requestId: req-al7
                    message: At most 12.50 USD is left to refund on this payment.
                    statusCode: 400
                idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `INSUFFICIENT_SCOPE` (the key is read-only or lacks the `refunds`
            scope), `FORBIDDEN` (a key for a different organization),
            `ACCOUNT_BLOCKED`, or `LIVE_KEY_ORG_NOT_APPROVED`. Refunds are not
            held back by business verification.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                missingScope:
                  value:
                    type: INSUFFICIENT_SCOPE
                    status: 403
                    detail: This API key was not granted the `refunds` scope, which this route requires. Issue a key with scopes ["write", "refunds"] (an owner or admin can). Nothing was changed.
                    requestId: req-am8
                    message: This API key was not granted the `refunds` scope, which this route requires. Issue a key with scopes ["write", "refunds"] (an owner or admin can). Nothing was changed.
                    statusCode: 403
                forbidden: { $ref: "#/components/examples/Forbidden" }
                accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
                liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `REFUND_IN_PROGRESS` (another refund on this payment is still
            being confirmed at the acquirer; nothing further was sent),
            `CONFLICT` (in the sandbox, another refund landed a moment
            earlier; read the payment again), or an idempotency conflict:
            `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`,
            `DUPLICATE_REQUEST_DETECTED`, or `PAYOUT_OUTCOME_UNKNOWN` on a
            replay of a key whose first call answered `500` (the refund may
            have gone through; read the payment, do not resend).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                inProgress:
                  value:
                    type: REFUND_IN_PROGRESS
                    status: 409
                    detail: A refund on this payment is still being confirmed. Read the payment again once it shows, then refund more if needed.
                    resolution: 'Nothing further was sent. One refund at a time per payment: read it, and once refundedBase reflects the earlier refund, send a new one if still needed.'
                    requestId: req-an9
                    message: A refund on this payment is still being confirmed. Read the payment again once it shows, then refund more if needed.
                    statusCode: 409
                refundedElsewhere:
                  value:
                    type: CONFLICT
                    status: 409
                    detail: This payment was refunded by somebody else a moment ago. Read it again.
                    requestId: req-aoc
                    message: This payment was refunded by somebody else a moment ago. Read it again.
                    statusCode: 409
                conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
                inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
                outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknownReplay" }
                duplicate:
                  value:
                    type: DUPLICATE_REQUEST_DETECTED
                    status: 409
                    detail: An identical request was received in the last 15 minutes under a different Idempotency-Key. Nothing was executed.
                    resolution: 'Nothing was executed. If this was a retry, send it again with originalIdempotencyKey. If you really meant to send twice, add the header X-Allow-Duplicate: true.'
                    originalIdempotencyKey: refund_1042_a
                    requestId: req-aod
                    message: An identical request was received in the last 15 minutes under a different Idempotency-Key. Nothing was executed.
                    statusCode: 409
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/OutcomeUnknown" }

  /checkout/organizations/{orgId}/payments/{paymentId}/simulate:
    post:
      tags: [Sandbox]
      operationId: simulateCheckoutPaymentOutcome
      summary: Simulate a payment outcome
      description: |
        Force an outcome on a sandbox payment.

        Move a simulated payment now, instead of waiting out its timeline. A
        test suite cannot sit for sixty seconds waiting for a chargeback, and a
        chargeback is the outcome worth rehearsing most.

        `chargeback` is `checkout_payment.reversed`: money taken back after
        you booked it. `refund` is a full refund. `fail` declines a payment
        still in flight.

        Safe alongside the timeline: both go through the same status-guarded
        transition, so forcing a chargeback and then letting the timeline run
        delivers exactly one `checkout_payment.reversed`. `moved` is false when
        the transition was refused (a terminal payment, or somebody got there first). That is normal and not an error.
      parameters:
        - $ref: "#/components/parameters/OrgId"
        - name: paymentId
          in: path
          required: true
          description: |
            The payment id, or the `reference` you created it with. A reference
            used on more than one of your links is ambiguous and answers `409`;
            pass the id instead.
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [action]
              properties:
                action:
                  type: string
                  enum: [refund, chargeback, fail]
              examples:
                - { action: chargeback }
      responses:
        "200":
          description: Where the payment stands, and whether this call moved it.
          content:
            application/json:
              schema:
                type: object
                required: [id, status, moved]
                properties:
                  id: { type: string }
                  status: { $ref: "#/components/schemas/CheckoutPaymentStatus" }
                  moved:
                    type: boolean
                    description: False when the transition was refused. Not an error.
              examples:
                chargedBack:
                  value: { id: cmf9x1b2c0003q8b7h6j8k0lm, status: reversed, moved: true }
        "400": { $ref: "#/components/responses/SandboxBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbiddenWrite" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /checkout/organizations/{orgId}/sandbox/scenarios:
    get:
      tags: [Sandbox]
      operationId: listCheckoutSandboxScenarios
      summary: List sandbox scenarios
      description: |
        What each amount does.

        The scenario table, as data. Read it rather than hardcoding the
        suffixes: this is the list the simulator itself runs on, so a scenario
        added later appears here without anyone editing a constant.

        `suffix` is the last two digits of the link's total in minor units.
        Anything not listed is paid immediately, so the happy path needs no magic amount. Read-only keys may call this.
      parameters:
        - $ref: "#/components/parameters/OrgId"
      responses:
        "200":
          description: Every scenario, and what it does.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [suffix, description]
                  properties:
                    suffix:
                      type: string
                      description: Two digits, or `anything else` for the default.
                    description: { type: string }
              examples:
                table:
                  value:
                    - suffix: "01"
                      description: The card is declined. Nothing is ever paid.
                    - suffix: "04"
                      description: Paid, then CHARGED BACK a minute later.
                    - suffix: anything else
                      description: Paid immediately.
        "400": { $ref: "#/components/responses/SandboxBadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/CheckoutForbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }


components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        **This key is a secret. Treat it like a database password.** Anyone
        holding it can move money on your organization's behalf, so it belongs
        in a secrets vault or an environment variable, never in source control, a client-side bundle, a mobile app, a screenshot, or a support
        ticket. If one is exposed, revoke it in the dashboard and issue a
        successor; the new key is live before the old one stops, so there is no
        window where neither works.

        The docs **Try It** console holds a test key only for the duration of your
        documentation session. Use only an `avvio_test_*` key there. In your own
        product, keep both test and live keys in server-side secret storage;
        never embed them in a browser bundle or mobile application.

        A key is scoped to one organization and rejected on any other's routes.
        It can move money. It cannot accept provider terms, manage your team or
        issue further keys; those stay human actions.

        Reduce the blast radius where you can. A key can be issued read-only,
        pinned to specific source addresses, and given an expiry. All three are set when you issue it, and all three are enforced on every request.

        Format: `avvio_live_` or `avvio_test_`, followed by a 32-character
        lowercase hexadecimal public id, `_`, and a 43-character base64url
        secret. Copy the entire value. Example:
        `avvio_test_0123456789abcdef0123456789abcdef_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`.

  parameters:
    OrgId:
      name: orgId
      in: path
      required: true
      description: |
        The opaque organization id issued to you, normally CUID-shaped (for
        example `cmsx…`). It is not an `org_`-prefixed alias. Pass it unchanged
        in every organization-scoped path.
      schema: { type: string, minLength: 1, examples: ["cmsx…"] }

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A unique value per logical operation, 1-255 chars of `A-Z a-z 0-9 _ . : -`.

        Reuse it to retry. Same key with the same body replays the stored
        response; same key with a *different* body is a `409`, because
        answering with the first call's result would hand you a receipt for a
        payout you did not request. A `4xx` releases the key, so you can fix the
        body and reuse it.

        **Reuse it; do not generate one per attempt.** A key minted per attempt
        defeats replay entirely: every retry looks like a new request, so every
        retry pays. We also watch for an identical body arriving under a
        *different* key within 15 minutes and refuse it with
        `DUPLICATE_REQUEST_DETECTED`.

        Records are kept for 7 days. That is a retention window only: there is no path where an expired key is re-executed.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: '^[A-Za-z0-9_.:-]+$'
        examples: ["5a81e921-bc01-447a-9a11-0982716a5b42"]

    AllowDuplicate:
      name: X-Allow-Duplicate
      in: header
      required: false
      description: |
        Set to `true` to send a request that is byte-identical to one you sent
        within the last 15 minutes under a different key. `true` is the only
        accepted value. It switches off the guard that catches a retry arriving
        under a fresh key, so send it only when you mean to pay twice.
      schema: { type: string }

  examples:
    Forbidden:
      summary: A key for a different organization
      value:
        type: FORBIDDEN
        status: 403
        detail: This API key cannot access that organization
        resolution: This key is scoped to a different organization.
        requestId: req-9d0
        message: This API key cannot access that organization
        statusCode: 403
    AccountBlocked:
      summary: API access suspended for the organization
      value:
        type: ACCOUNT_BLOCKED
        status: 403
        detail: API access for this organization has been suspended.
        requestId: req-9d1
        message: API access for this organization has been suspended.
        statusCode: 403
    LiveKeyOrgNotApproved:
      summary: A live key writing before business verification is approved
      value:
        type: LIVE_KEY_ORG_NOT_APPROVED
        status: 403
        detail: Live API keys can move money once Avvio approves your business verification. Use a test key until then. Nothing was changed.
        requestId: req-9d2
        message: Live API keys can move money once Avvio approves your business verification. Use a test key until then. Nothing was changed.
        statusCode: 403
    InsufficientScope:
      summary: A read-only key on a write
      value:
        type: INSUFFICIENT_SCOPE
        status: 403
        detail: This API key is read-only and cannot POST /business/api/v1/payments/organizations/cmsx0h2k900a1n11ib3qz9v42/payouts. Issue a key with the `write` scope to move money.
        requestId: req-9d3
        message: This API key is read-only and cannot POST /business/api/v1/payments/organizations/cmsx0h2k900a1n11ib3qz9v42/payouts. Issue a key with the `write` scope to move money.
        statusCode: 403
    KeyExpired:
      summary: The key passed its expiry
      value:
        type: KEY_EXPIRED
        status: 401
        detail: This key expired on 2026-09-01T00:00:00.000Z. Issue a new key in the dashboard; an expired key cannot be rotated.
        requestId: req-9c0
        message: This key expired on 2026-09-01T00:00:00.000Z. Issue a new key in the dashboard; an expired key cannot be rotated.
        statusCode: 401
    KeyIpNotAllowed:
      summary: A pinned key used from another address
      value:
        type: KEY_IP_NOT_ALLOWED
        status: 401
        detail: This key is pinned to specific source addresses and this request did not come from one.
        requestId: req-9c1
        message: This key is pinned to specific source addresses and this request did not come from one.
        statusCode: 401
    IdempotencyKeyInvalid:
      summary: A malformed Idempotency-Key
      value:
        type: IDEMPOTENCY_KEY_INVALID
        status: 400
        detail: "Idempotency-Key must be 1-255 characters of A-Z a-z 0-9 _ . : or -"
        resolution: "Use 1-255 characters of A-Z a-z 0-9 _ . : or -. A UUID works."
        requestId: req-9f3
        message: "Idempotency-Key must be 1-255 characters of A-Z a-z 0-9 _ . : or -"
        statusCode: 400
    IdempotencyKeyConflict:
      summary: The key was reused with a different body
      value:
        type: IDEMPOTENCY_KEY_CONFLICT
        status: 409
        detail: This Idempotency-Key was already used with a different request body. Use a new key for a different request.
        resolution: This key was used with a different body. Do not retry — use a new key for a new request.
        requestId: req-9g3
        message: This Idempotency-Key was already used with a different request body. Use a new key for a different request.
        statusCode: 409
    IdempotencyKeyInProgress:
      summary: The first request with this key is still running
      value:
        type: IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS
        status: 409
        detail: A request with this Idempotency-Key is still in flight.
        resolution: An identical request is still running. Back off and retry the SAME key.
        requestId: req-9h4
        message: A request with this Idempotency-Key is still in flight.
        statusCode: 409
    OutcomeUnknown:
      summary: First call, outcome unknown
      value:
        type: PAYOUT_OUTCOME_UNKNOWN
        status: 500
        detail: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        resolution: The payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference> (or replay the same key, which answers 409 until we resolve it) and contact support with the requestId.
        requestId: req-2k9f
        message: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        statusCode: 500
    OutcomeUnknownReplay:
      summary: A replay of that key, still unresolved
      value:
        type: PAYOUT_OUTCOME_UNKNOWN
        status: 409
        detail: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        resolution: The payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference> (or replay the same key, which answers 409 until we resolve it) and contact support with the requestId.
        originalRequestId: req-2k9f
        requestId: req-9i5
        message: 'The outcome of this request is unknown: the payout may have been created. Do not send a new Idempotency-Key. Poll GET /orders?reference=<your reference>, or replay this key, which answers 409 until we resolve it. Contact support with the requestId.'
        statusCode: 409
    DuplicateRequest:
      summary: An identical body under a different key
      value:
        type: DUPLICATE_REQUEST_DETECTED
        status: 409
        detail: An identical request was received in the last 15 minutes under a different Idempotency-Key. Nothing was executed.
        resolution: 'Nothing was executed. If this was a retry, send it again with originalIdempotencyKey. If you really meant to send twice, add the header X-Allow-Duplicate: true.'
        originalIdempotencyKey: zz_advance_88213
        originalPayoutId: sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11
        requestId: req-9j6
        message: An identical request was received in the last 15 minutes under a different Idempotency-Key. Nothing was executed.
        statusCode: 409

  responses:
    Unauthorized:
      description: |
        The key was refused. Nothing ran.

        - `UNAUTHORIZED`: missing, invalid or revoked, or a key on a route that
          does not accept one.
        - `KEY_EXPIRED`: the key passed the expiry it was issued with. Issue a
          new one; an expired key cannot be rotated.
        - `KEY_IP_NOT_ALLOWED`: the key is pinned to source addresses and this
          request came from another.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            unauthorized:
              value:
                type: UNAUTHORIZED
                status: 401
                detail: Invalid API key
                resolution: Check the API key was copied whole and has not been revoked.
                requestId: req-9bz
                message: Invalid API key
                statusCode: 401
            keyExpired: { $ref: "#/components/examples/KeyExpired" }
            keyIpNotAllowed: { $ref: "#/components/examples/KeyIpNotAllowed" }
    Forbidden:
      description: |
        A valid key that may not make this call. Nothing ran.

        - `FORBIDDEN`: the key belongs to a different organization.
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended, and
          every key is refused until we lift it. Contact support.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
    ForbiddenWrite:
      description: |
        A valid key that may not make this write. Nothing was changed.

        - `FORBIDDEN`: the key belongs to a different organization.
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended.
        - `LIVE_KEY_ORG_NOT_APPROVED`: a live key, before we have approved your
          business verification. Use a test key until then.
        - `INSUFFICIENT_SCOPE`: a read-only key. Issue one with the `write`
          scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
            liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
            insufficientScope: { $ref: "#/components/examples/InsufficientScope" }
    RateLimited:
      description: |
        Too many requests. The default ceiling is **100 requests per minute per
        API credential** on a 60-second window. High-volume payout and
        reconciliation routes declare a 600/minute override, and batch
        submission a 30/minute ceiling. A separate 2,000/minute per-source-IP
        abuse ceiling always applies.

        Obey `Retry-After`; it is in seconds and is authoritative. A 429 means
        the request was refused before the handler ran. Retry reads normally;
        retry an idempotent mutation with its same `Idempotency-Key`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. Authoritative.
          schema: { type: integer }
        X-RateLimit-Limit:
          description: Requests permitted in the window.
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Requests left in the window; `0` on a 429.
          schema: { type: integer }
        X-RateLimit-Reset:
          description: Seconds until the window resets.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            rateLimited:
              value:
                type: RATE_LIMITED
                status: 429
                detail: 'ThrottlerException: Too Many Requests'
                resolution: Back off and retry after the Retry-After interval.
                requestId: req-9e1
                message: 'ThrottlerException: Too Many Requests'
                statusCode: 429
    IdempotencyConflict:
      description: |
        Either the key was reused with a different body
        (`IDEMPOTENCY_KEY_CONFLICT`: use a new key), or the first request with
        this key is still running (`IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`: back
        off and retry the same key).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
            inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
    MoneyIdempotencyConflict:
      description: |
        On a route that moves money:

        - `IDEMPOTENCY_KEY_CONFLICT`: the key was reused with a different body.
          Use a new key for a new request.
        - `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`: the first request with this key
          is still running. Back off and retry the same key.
        - `PAYOUT_OUTCOME_UNKNOWN`: a replay of a key whose first call answered
          `500 PAYOUT_OUTCOME_UNKNOWN`. Money may have moved. Do not send a new
          key; look the outcome up first. `originalRequestId` names the first
          call.
        - `DUPLICATE_REQUEST_DETECTED`: an identical body under a *different*
          key within 15 minutes. Nothing was executed. Retry with
          `originalIdempotencyKey`, or send `X-Allow-Duplicate: true` if you
          mean to pay twice.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
            inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
            outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknownReplay" }
            duplicate: { $ref: "#/components/examples/DuplicateRequest" }
    OutcomeUnknown:
      description: |
        `PAYOUT_OUTCOME_UNKNOWN`: the call failed after the money may have
        moved, and we cannot yet say whether it did. **Do not retry with a new
        `Idempotency-Key`**; that is how a payment goes out twice. Look the
        outcome up first (the operation says where), or replay the same key,
        which answers `409 PAYOUT_OUTCOME_UNKNOWN` until we resolve it. Send
        support the `requestId`.

        Every other `500` is `INTERNAL`: nothing was recorded under the key, and
        retrying with the same key is safe.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            outcomeUnknown: { $ref: "#/components/examples/OutcomeUnknown" }

    CheckoutForbidden:
      description: |
        A valid key that may not make this call. Nothing ran.

        - `FORBIDDEN`: the key belongs to a different organization, **or**
          your business has not completed verification to accept payments
          (every checkout call but refund is refused until it has; `detail`
          says which).
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            merchantUnverified:
              value:
                type: FORBIDDEN
                status: 403
                detail: This organization must complete business verification before it can accept payments.
                resolution: This key is scoped to a different organization.
                requestId: req-9d7
                message: This organization must complete business verification before it can accept payments.
                statusCode: 403
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
    CheckoutForbiddenWrite:
      description: |
        A valid key that may not make this write. Nothing was changed.

        - `FORBIDDEN`: the key belongs to a different organization, **or**
          your business has not completed verification to accept payments;
          `detail` says which.
        - `ACCOUNT_BLOCKED`: API access for your organization is suspended.
        - `LIVE_KEY_ORG_NOT_APPROVED`: a live key, before we have approved your
          business verification. Use a test key until then.
        - `INSUFFICIENT_SCOPE`: a read-only key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            forbidden: { $ref: "#/components/examples/Forbidden" }
            merchantUnverified:
              value:
                type: FORBIDDEN
                status: 403
                detail: This organization must complete business verification before it can accept payments.
                resolution: This key is scoped to a different organization.
                requestId: req-9d7
                message: This organization must complete business verification before it can accept payments.
                statusCode: 403
            accountBlocked: { $ref: "#/components/examples/AccountBlocked" }
            liveKeyOrgNotApproved: { $ref: "#/components/examples/LiveKeyOrgNotApproved" }
            insufficientScope:
              value:
                type: INSUFFICIENT_SCOPE
                status: 403
                detail: This API key is read-only and cannot POST /business/api/v1/checkout/organizations/cmsx0f3a90000q8b7k2m4n6p8/links. Issue a key with the `write` scope to move money.
                requestId: req-ai4
                message: This API key is read-only and cannot POST /business/api/v1/checkout/organizations/cmsx0f3a90000q8b7k2m4n6p8/links. Issue a key with the `write` scope to move money.
                statusCode: 403
    LinkConflict:
      description: |
        `CONFLICT`: another change to this link is in flight; retry after a
        moment. Or an idempotency conflict (`IDEMPOTENCY_KEY_CONFLICT`,
        `IDEMPOTENCY_KEY_REQUEST_IN_PROGRESS`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            beingChanged:
              value:
                type: CONFLICT
                status: 409
                detail: This checkout link is being changed. Try again in a moment.
                requestId: req-aj5
                message: This checkout link is being changed. Try again in a moment.
                statusCode: 409
            conflict: { $ref: "#/components/examples/IdempotencyKeyConflict" }
            inFlight: { $ref: "#/components/examples/IdempotencyKeyInProgress" }
    NotFound:
      description: No such link or product in this organization, or the id belongs to another one.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            notFound:
              value:
                type: NOT_FOUND
                status: 404
                detail: Checkout link not found
                requestId: req-9qd
                message: Checkout link not found
                statusCode: 404
    CheckoutBadRequest:
      description: |
        `VALIDATION_ERROR` (a field is malformed; `errors` names each),
        `BAD_REQUEST` (a request we understood but cannot carry out, named in
        `detail`: editing a published link, deleting one that was live, both
        `productId` and `items`), `LINK_EXPIRED` (publishing a draft whose
        `expiresAt` has passed), or, on a write, `IDEMPOTENCY_KEY_REQUIRED` /
        `IDEMPOTENCY_KEY_INVALID` (the header is missing or malformed).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            validation:
              value:
                type: VALIDATION_ERROR
                status: 400
                detail: 1 field(s) failed validation
                resolution: Correct the fields listed in `errors` and retry.
                errors:
                  - amount must be a decimal string with at most 2 decimal places, e.g. "49.00"
                requestId: req-9re
                message: 1 field(s) failed validation
                statusCode: 400
            expired:
              value:
                type: LINK_EXPIRED
                status: 400
                detail: This link's expiresAt (2026-09-01T09:00:00.000Z) has passed. Set a later expiresAt, or create a new link.
                requestId: req-9sf
                message: This link's expiresAt (2026-09-01T09:00:00.000Z) has passed. Set a later expiresAt, or create a new link.
                statusCode: 400
            idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }

    SandboxBadRequest:
      description: |
        `LIVE_MODE_UNSUPPORTED`: a live key on a sandbox-only operation. These
        manufacture payments and the events that follow them, so they are
        refused a live credential whatever organization the path names. Use an
        `avvio_test_*` key.

        Also `BAD_REQUEST` when the link cannot take a payment (a draft, a
        paused or expired link, or one that has already taken the maximum
        number of simulated payments), `LINK_EXPIRED`, `VALIDATION_ERROR`,
        `IDEMPOTENCY_KEY_REQUIRED` and `IDEMPOTENCY_KEY_INVALID`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            liveKey:
              value:
                type: LIVE_MODE_UNSUPPORTED
                status: 400
                detail: This is only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                requestId: req-9tg
                message: This is only available with a test API key (avvio_test_…) or from the sandbox environment in the dashboard.
                statusCode: 400
            idempotencyKeyInvalid: { $ref: "#/components/examples/IdempotencyKeyInvalid" }

  schemas:
    Error:
      type: object
      description: |
        Branch on `type`, never on the HTTP status or the message. `type` is stable across versions; the prose is not.
      required: [type, detail, message, status, statusCode, requestId]
      properties:
        type:
          type: string
          description: Stable machine-readable code.
          examples: ["IDEMPOTENCY_KEY_CONFLICT", "DUPLICATE_REQUEST_DETECTED"]
        detail:
          type: string
          description: |
            What went wrong, in a sentence. Always a string, so
            `detail.toLowerCase()` is safe.

            This is the field to read on `BAD_REQUEST` and
            `PROVIDER_REJECTED`, where the type alone does not name the
            condition.
        message:
          type: string
          deprecated: true
          description: The same text as `detail`, kept for integrations written before `detail` existed. Read `detail`.
        resolution:
          type: string
          description: |
            What to do about it, when there is a specific answer. It is not on every error (it is absent on `BAD_REQUEST`, `NOT_FOUND`, `PAYOUT_NOT_CANCELABLE` and `DESTINATION_ACCOUNT_NOT_FOUND`), so treat it as optional and fall back to `detail`.
        status:
          type: integer
          description: HTTP status, repeated in the body.
        statusCode:
          type: integer
          deprecated: true
          description: The same value as `status`, kept for integrations written before `status` existed. Read `status`.
        requestId:
          type: string
          description: |
            Quote this to support and we can find the exact request. Also sent
            as the `x-request-id` response header, which is the only place it appears on a successful response. Success bodies do not carry it.
            Send your own `x-request-id` on the request and we use it, so your
            trace and ours share one identifier; otherwise we mint one.
        errors:
          type: array
          items: { type: string }
          description: Present on VALIDATION_ERROR; names each field that failed.
        originalIdempotencyKey:
          type: string
          description: |
            On `DUPLICATE_REQUEST_DETECTED` only. Send the request again with
            this to receive the original payout instead of making a second one.
            Without it there is no way to recover except by risking a double
            payment.
        originalPayoutId:
          type: string
          description: On `DUPLICATE_REQUEST_DETECTED` only. The payout the first request created.
        originalBatchId:
          type: string
          description: On a batch `DUPLICATE_REQUEST_DETECTED`. The run the first request created.
        originalRequestId:
          type: string
          description: |
            On a `409 PAYOUT_OUTCOME_UNKNOWN` replay. The `requestId` of the
            call whose outcome is unknown; quote it to support.
        existingRecipientId:
          type: string
          description: On `BANK_ACCOUNT_ALREADY_LINKED`. The recipient in your organization that already holds this account.
        existingMethodId:
          type: string
          description: On `BANK_ACCOUNT_ALREADY_LINKED`. The payment method on that recipient.


    CheckoutMethodKind:
      type: string
      enum: [card, bank, crypto, cashapp]
      description: |
        How a buyer may pay. `card` is the hosted card page (card, wallets and
        PayPal in one).

    CheckoutLineItem:
      type: object
      description: |
        One line on the link. Money is decimal strings in the link's currency.
        Every field is optional on a request: `quantity` defaults to `"1"`,
        `unitAmount` to `"0"`, and a blank line is dropped.
      properties:
        id: { type: string, description: "Response only. The line's id." }
        description: { type: string, maxLength: 200 }
        detail: { type: [string, "null"], maxLength: 200 }
        quantity:
          type: string
          pattern: '^\d{1,12}(\.\d{1,6})?$'
          examples: ["1"]
        unitAmount:
          type: string
          pattern: '^\d{1,12}(\.\d{1,6})?$'
          examples: ["150.00"]
        amount:
          type: string
          description: "`quantity × unitAmount`, rounded once at the currency's precision. Response only."

    CheckoutMethodInput:
      type: object
      description: |
        A rail the link offers. `card` needs nothing else. `bank` needs
        `bankAccountRef`, the account the buyer pays into, as your dashboard's
        bank-method form describes it for that currency; `crypto` needs `token`,
        `chain` and `depositAddress`. A rail with nothing to pay into is a
        `400` naming the kind.
      required: [kind]
      properties:
        kind: { $ref: "#/components/schemas/CheckoutMethodKind" }
        enabled: { type: boolean, default: true }
        bankAccountRef:
          type: object
          additionalProperties: true
          description: Bank rail only. The receiving account snapshot; at least `beneficiaryName` and an account identifier.
        token:
          type: string
          description: "Crypto rail only, e.g. `USDC`. One of the supported tokens (`USDC`, `USDT`, `EURC`, `DAI`, `PYUSD`, `BTC`, `ETH`, `SOL`, …, upper or lower case); anything else is `400 VALIDATION_ERROR`."
        chain:
          type: string
          description: "Crypto rail only, e.g. `base`. One of the supported networks (`base`, `ethereum`, `polygon`, `arbitrum`, `optimism`, `solana`, `bitcoin`, `bnb`, …); anything else is `400 VALIDATION_ERROR`."
        depositAddress: { type: string, maxLength: 128, description: "Crypto rail only." }
        currency:
          type: string
          enum: [USD, EUR, GBP, AED, MXN, BRL, ARS, INR, CNY, HKD, PHP, SGD, IDR, THB]
          description: Bank rail only. The account's currency when it differs from the link's.

    CheckoutMethod:
      type: object
      description: A rail as a link read returns it.
      required: [kind, enabled]
      properties:
        kind: { $ref: "#/components/schemas/CheckoutMethodKind" }
        enabled: { type: boolean }
        token: { type: [string, "null"] }
        chain: { type: [string, "null"] }
        currency: { type: [string, "null"] }
        depositAddress: { type: [string, "null"] }
        bankAccountRef:
          type: [object, "null"]
          additionalProperties: true
          description: Bank rail only. The receiving account as you stored it on the link; the same object the public payer page shows a buyer.
        cashAppPayload: { type: [object, "null"] }

    CheckoutReceived:
      type: object
      description: |
        What the link has taken in, summed over its `paid` and `refunded`
        payments. Base units with `decimals` beside them. Null on the link
        until the first payment lands, so "never paid" is not a zero.
      required: [count, grossBase, refundedBase, currency, decimals]
      properties:
        count: { type: integer, description: "Qualifying payments. Pending and reversed rows are not counted." }
        grossBase: { type: string, description: "What arrived." }
        refundedBase: { type: string, description: "What went back." }
        currency: { type: string }
        decimals: { type: integer }

    CreateCheckoutLinkRequest:
      type: object
      description: |
        Either `productId`, or `currency` and `items`. Both is a `400`.
        Everything else is optional.
      properties:
        productId:
          type: string
          format: uuid
          description: Sell a catalog product; its name and price become the one line item and its currency the link's.
        currency:
          type: string
          enum: [USD, EUR, GBP, AED, MXN, BRL, ARS, INR, CNY, HKD, PHP, SGD, IDR, THB]
          description: Required without `productId`. Card links are refused in currencies the processor cannot price; the error names it.
        items:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: "#/components/schemas/CheckoutLineItem" }
          description: Required without `productId`.
        methods:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/CheckoutMethodInput" }
          description: |
            Omitted means `[{ "kind": "card" }]`. An empty array is a link
            nothing can pay.
        fromName:
          type: string
          maxLength: 200
          description: The name the buyer sees. Defaults to your organization's name.
        memo: { type: string, maxLength: 500, description: "A note shown to the buyer." }
        taxRate:
          type: string
          pattern: '^(100(\.0{1,2})?|\d{1,2}(\.\d{1,6})?)$'
          description: Percent, as a decimal string.
        taxName: { type: string, maxLength: 40 }
        taxInclusive:
          type: boolean
          default: false
          description: When true the tax is disclosed inside the total rather than added to it.
        statementDescriptor:
          type: string
          maxLength: 17
          description: What the buyer's card statement says, without the processor's prefix. Defaults to your registered descriptor, then `fromName`.
        adaptivePricing:
          type: boolean
          default: false
          description: Card rail only. The buyer sees the price in their own currency and pays on a domestic rail; the link stays priced and reported in `currency`.
        successUrl:
          type: string
          format: uri
          maxLength: 2048
          description: |
            Where the payer page sends the buyer after
            a card payment, with `avvio_link=<slug>` (the link's public slug,
            the last path segment of `shareUrl`) and, when known,
            `client_reference_id=<ref>` appended. **https only**. Bank and crypto payments settle later and never
            redirect. Never trust the
            redirect alone: confirm on the webhook or `GET /links/{linkId}/payments`.
          examples: ["https://example.com/thanks"]
        cancelUrl:
          type: string
          format: uri
          maxLength: 2048
          description: Rendered as a "Back to {merchant}" link on the payer page. Same rule as `successUrl`.
          examples: ["https://example.com/pricing"]
        clientReferenceId:
          type: string
          pattern: '^[A-Za-z0-9_-]{1,200}$'
          description: Your id for what this link pays for (an order, a booking), echoed on every payment event. The authoritative join, on every rail.
          examples: [order_1042]
        metadata:
          type: object
          additionalProperties: { type: string, maxLength: 500 }
          maxProperties: 50
          description: Up to 50 keys of at most 40 characters with string values of at most 500. Echoed on every payment event, never shown to a buyer.
          examples:
            - { orderId: "1042" }
        expiresAt:
          type: string
          format: date-time
          description: |
            When the link stops taking payments: an
            ISO-8601 instant with a timezone (`2026-10-01T09:00:00Z`), in the
            future, at most a year away. From then the page answers `410` and
            `status` reads `expired`. Omitted means the link runs until it is
            paused. The one field a live link may still change, on its own.
          examples: ["2026-10-01T09:00:00Z"]
        publish:
          type: boolean
          default: false
          description: Create and publish in one call, so the response carries `shareUrl`. On a publish failure the draft is kept and named in the error.
      example:
        productId: 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d
        successUrl: https://example.com/thanks
        cancelUrl: https://example.com/pricing
        clientReferenceId: order_1042
        metadata: { orderId: "1042" }
        publish: true

    UpdateCheckoutLinkRequest:
      type: object
      description: |
        Every field of `CreateCheckoutLinkRequest` except `publish`, all
        optional. Drafts only, except `expiresAt` alone, which a live link
        also takes. Sending `items` or `currency` on a product link detaches
        it from the product; `metadata` and `methods` are replaced whole.
      properties:
        productId: { type: [string, "null"], format: uuid }
        currency:
          type: string
          enum: [USD, EUR, GBP, AED, MXN, BRL, ARS, INR, CNY, HKD, PHP, SGD, IDR, THB]
        items:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: "#/components/schemas/CheckoutLineItem" }
        methods:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/CheckoutMethodInput" }
        fromName: { type: string, maxLength: 200 }
        memo: { type: string, maxLength: 500 }
        taxRate:
          type: string
          pattern: '^(100(\.0{1,2})?|\d{1,2}(\.\d{1,6})?)$'
          description: Percent, as a decimal string. **Only applied together with `items`**; sent alone it is ignored.
        taxName: { type: string, maxLength: 40 }
        taxInclusive:
          type: boolean
          description: Only applied together with `items`; sent alone it is ignored.
        statementDescriptor: { type: string, maxLength: 17 }
        adaptivePricing: { type: boolean }
        successUrl: { type: string, format: uri, maxLength: 2048 }
        cancelUrl: { type: string, format: uri, maxLength: 2048 }
        clientReferenceId: { type: string, pattern: '^[A-Za-z0-9_-]{1,200}$' }
        metadata:
          type: object
          additionalProperties: { type: string, maxLength: 500 }
          maxProperties: 50
        expiresAt:
          type: [string, "null"]
          format: date-time
          description: A new deadline (future, within a year, with a timezone), or `null` to remove it. Accepted on a live link when sent on its own.
      example:
        successUrl: https://example.com/thanks?v=2
        metadata: { orderId: "1042", campaign: spring }

    CheckoutLink:
      type: object
      description: |
        A checkout link as you read it. Underneath it is the same object as a
        hosted invoice, so fields you do not recognize may appear; ignore them.
        The customer fields (`customerName` and friends) are always null: a
        link is addressed to nobody and any number of buyers may pay it.

        Money on the link (`subtotal`, `taxTotal`, `total`, item amounts) is
        decimal strings in `currency`. `received` is base units, the same
        convention as `CheckoutPayment`.
      required: [id, slug, status, shareUrl, fromName, currency, total, createdAt, product, received, successUrl, cancelUrl, clientReferenceId, metadata, expiresAt, source]
      properties:
        id: { type: string, description: "Use this in every /links/{linkId} path." }
        number:
          type: string
          description: "Inherited from the invoice shape. Always `CHECKOUT` on a link; internal, never shown to a buyer and never the wire reference (that is `slug`)."
        paymentReference:
          type: [string, "null"]
          description: Inherited from the invoice shape. Null on a link; a bank payer references the `slug`.
        deepLink:
          type: [string, "null"]
          description: "Inherited from the invoice shape: an `avvio://` app link to the same page."
        fromEmail:
          type: [string, "null"]
          description: Inherited from the invoice shape. Your contact email as shown to the buyer, when set.
        fromAddress:
          type: [string, "null"]
          description: Inherited from the invoice shape. Your address as shown to the buyer, when set.
        customerName:
          type: "null"
          description: Inherited from the invoice shape. Always null; a link is addressed to nobody.
        customerEmail: { type: "null", description: Always null on a link. }
        customerAddress: { type: "null", description: Always null on a link. }
        customerId: { type: "null", description: Always null on a link. }
        feeTotal:
          type: string
          description: "Inherited from the invoice shape. `\"0\"` on a link; processing fees are on each payment, not the link."
        settlementCurrency:
          type: string
          description: Inherited from the invoice shape. The stablecoin the crypto rail settles in when offered; informational on a card link.
        settlementAmount:
          type: string
          description: Inherited from the invoice shape. The total in `settlementCurrency`.
        dueType:
          type: string
          description: "Inherited from the invoice shape. Always `on_receipt` on a link; a link has no due date."
        dueDate: { type: "null", description: Always null on a link. }
        paidAt:
          type: "null"
          description: Always null on a link. A link never closes; each payment carries its own `paidAt`.
        emailedAt: { type: "null", description: Always null on a link. Links are not emailed to a customer. }
        slug:
          type: string
          description: The public page's path segment (the end of `shareUrl`), the `avvio_link` value on a success redirect, and the reference a bank payer types.
        status:
          type: string
          enum: [draft, sent, cancelled, expired]
          description: "`draft` is not publicly readable. `sent` is live. `cancelled` is paused and `expired` is past its `expiresAt`: both are terminal, and the page answers 410."
        shareUrl:
          type: [string, "null"]
          format: uri
          description: The page to send buyers to. Null only when the environment has no public page URL configured.
        fromName: { type: string }
        currency: { type: string }
        subtotal: { type: string }
        taxRate: { type: string }
        taxName: { type: [string, "null"] }
        taxInclusive: { type: boolean }
        taxTotal: { type: string }
        total: { type: string, description: "What one buyer pays." }
        memo: { type: [string, "null"] }
        statementDescriptor: { type: [string, "null"] }
        adaptivePricing: { type: boolean }
        issuedAt: { type: [string, "null"], format: date-time, description: "When it was published." }
        createdAt: { type: string, format: date-time }
        items:
          type: array
          items: { $ref: "#/components/schemas/CheckoutLineItem" }
        methods:
          type: array
          items: { $ref: "#/components/schemas/CheckoutMethod" }
        product:
          type: [object, "null"]
          description: The catalog product it was made from; null for an ad-hoc link.
          required: [id, name]
          properties:
            id: { type: string }
            name: { type: string }
        received:
          oneOf:
            - $ref: "#/components/schemas/CheckoutReceived"
            - type: "null"
        successUrl: { type: [string, "null"], format: uri }
        cancelUrl: { type: [string, "null"], format: uri }
        clientReferenceId: { type: [string, "null"] }
        metadata:
          type: [object, "null"]
          additionalProperties: { type: string }
        expiresAt:
          type: [string, "null"]
          format: date-time
          description: When the link stops taking payments, or null for "until paused". Past it the page answers 410 and `status` reads `expired` within a minute.
        source:
          type: string
          enum: [api, dashboard]
          description: Who made the link (your server through an API key, or a person in the dashboard). The dashboard lists the two apart.
      example:
        id: 4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10
        number: CHECKOUT
        paymentReference: null
        deepLink: avvio://i/7e2a9c4b1d0f
        slug: 7e2a9c4b1d0f
        status: sent
        shareUrl: https://business.avvio.xyz/i/7e2a9c4b1d0f
        fromName: Northstar Consulting
        fromEmail: null
        fromAddress: null
        customerName: null
        customerEmail: null
        customerAddress: null
        customerId: null
        feeTotal: "0"
        settlementCurrency: USDC
        settlementAmount: "150.00"
        dueType: on_receipt
        dueDate: null
        paidAt: null
        emailedAt: null
        currency: USD
        subtotal: "150.00"
        taxRate: "0"
        taxName: null
        taxInclusive: false
        taxTotal: "0.00"
        total: "150.00"
        memo: null
        statementDescriptor: null
        adaptivePricing: false
        issuedAt: "2026-09-12T09:58:00.000Z"
        createdAt: "2026-09-12T09:58:00.000Z"
        items:
          - id: 9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a
            description: Consulting (60 min)
            detail: null
            quantity: "1"
            unitAmount: "150.00"
            amount: "150.00"
        methods:
          - kind: card
            enabled: true
            token: null
            chain: null
            currency: null
            depositAddress: null
            bankAccountRef: null
            cashAppPayload: null
        product:
          id: 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d
          name: Consulting (60 min)
        received: null
        successUrl: https://example.com/thanks
        cancelUrl: https://example.com/pricing
        clientReferenceId: order_1042
        metadata: { orderId: "1042" }
        expiresAt: null
        source: api

    CheckoutLinkCreated:
      description: |
        The body of `POST /links`: a `CheckoutLink`, plus `publishError` when
        `publish: true` was refused. Every other read returns a plain
        `CheckoutLink`.
      allOf:
        - $ref: "#/components/schemas/CheckoutLink"
        - type: object
          properties:
            publishError:
              type: object
              description: |
                Only when `publish: true` was refused. The link is a kept
                `draft` (`status: "draft"`); this says why. Absent otherwise.
              required: [status, type, message]
              properties:
                status: { type: integer, description: The HTTP status the publish would have answered. }
                type: { type: string, description: "The same vocabulary as the error envelope's `type`: `BAD_REQUEST`, `CONFLICT`, `NOT_FOUND`, `FORBIDDEN`, `INTERNAL`, or a more specific code." }
                message: { type: string }

    CheckoutPaymentStatus:
      type: string
      enum: [pending, processing, paid, failed, reversed, refunded]
      description: |
        `paid` is not final: `refunded` and `reversed` (a chargeback) can
        follow, weeks later. `pending` on a bank payment that has settled
        means it is held (short of the total, or in the wrong currency) and
        `reviewReason` says why; it moves when someone accepts it in the
        dashboard.

    CheckoutPayment:
      type: object
      description: |
        One buyer's payment on a link. Money is **base units** with `decimals`
        beside it; the webhook body for the same payment uses decimal strings.
        `refundedBase` is the cumulative figure and stays on a `paid` row after
        a partial refund, because a partly refunded payment is still a
        payment; `refunds` lists the pieces it is made of.
      required: [id, kind, amountBase, currency, decimals, feeBase, status, createdAt, refunds]
      properties:
        id: { type: string }
        kind: { $ref: "#/components/schemas/CheckoutMethodKind" }
        acceptor:
          type: string
          description: |
            Which side recorded it: `sandbox` for a simulated payment,
            `manual` for one you recorded in the dashboard. A live card
            payment carries an internal label for the card processor, which
            can change without notice. Do not branch on it.
        externalId: { type: string, description: "The acceptor's id for it. Opaque." }
        amountBase: { type: string, description: "Gross, base units." }
        currency: { type: string }
        decimals: { type: integer }
        feeBase: { type: string, description: "Processing fee, base units. `\"0\"` when none was stated." }
        netBase: { type: [string, "null"], description: "What reached your balance, when the acceptor stated it." }
        applicationFeeBase: { type: string, description: "Our fee, base units." }
        refundedBase: { type: [string, "null"], description: "Cumulative refunded, or null when nothing has been." }
        refunds:
          type: array
          description: Every refund on this payment, oldest first, with who asked for it and why. Empty when none.
          items: { $ref: "#/components/schemas/CheckoutRefund" }
        disputeSubstatus: { type: [string, "null"], description: "Set while a dispute is open. No event fires until it resolves." }
        status: { $ref: "#/components/schemas/CheckoutPaymentStatus" }
        failureCode: { type: [string, "null"] }
        reviewReason:
          type: [string, "null"]
          description: Why a settled bank deposit is held in `pending`, in words. Null on anything not held.
        clientReferenceId:
          type: [string, "null"]
          description: The `?client_reference_id=` the buyer's visit carried (card rail only). The link-level one is on the link.
        paidAt: { type: [string, "null"], format: date-time }
        reversedAt: { type: [string, "null"], format: date-time }
        settledAt:
          type: [string, "null"]
          format: date-time
          description: Null in this version; the money is in your balance at the processor and we do not see it move from there.
        createdAt: { type: string, format: date-time }
      example:
        id: cmf9x1b2c0003q8b7h6j8k0lm
        kind: card
        acceptor: sandbox
        externalId: pay_01J8
        amountBase: "15000"
        currency: USD
        decimals: 2
        feeBase: "435"
        netBase: "14565"
        applicationFeeBase: "150"
        refundedBase: null
        refunds: []
        disputeSubstatus: null
        status: paid
        failureCode: null
        reviewReason: null
        clientReferenceId: order_1042
        paidAt: "2026-09-12T10:00:04.000Z"
        reversedAt: null
        settledAt: null
        createdAt: "2026-09-12T09:59:58.000Z"

    RefundReason:
      type: string
      enum: [requested_by_customer, duplicate, fraudulent, other]
      description: The four refund reasons most card APIs use.

    RefundCheckoutPaymentRequest:
      type: object
      additionalProperties: false
      description: All three optional. An empty body refunds everything still refundable.
      properties:
        amount:
          type: string
          maxLength: 40
          pattern: '^\d+(\.\d+)?$'
          description: Decimal string in the payment's currency, at most as many decimal places as the currency has. Omit for everything remaining.
          examples: ["12.50"]
        reason: { $ref: "#/components/schemas/RefundReason" }
        note:
          type: string
          maxLength: 255
          description: For your own team. Never shown to the buyer; echoed on the event.
      example:
        amount: "12.50"
        reason: requested_by_customer
        note: Session moved to next week

    CheckoutRefund:
      type: object
      description: |
        One refund on a payment: the piece that moved, not the cumulative
        figure (that is `refundedBase` on the payment). `source` says who asked
        for it: `dashboard`, `api`, or `acquirer` for a refund made at the
        card processor's own tools that nobody initiated here. `pending` is a
        refund we asked for and have not yet seen come back; do not branch on
        values beyond `pending` and `succeeded`.
      required: [id, paymentId, amountBase, currency, decimals, reason, note, source, status, actor, createdAt]
      properties:
        id: { type: string }
        paymentId: { type: string }
        amountBase: { type: string, description: "This refund, base units." }
        currency: { type: string }
        decimals: { type: integer }
        reason:
          oneOf:
            - $ref: "#/components/schemas/RefundReason"
            - type: "null"
        note: { type: [string, "null"] }
        source:
          type: string
          enum: [dashboard, api, acquirer]
        status:
          type: string
          enum: [pending, succeeded]
        actor:
          type: [string, "null"]
          description: A teammate's name, or the API key's name. Null for a refund made at the acquirer.
        createdAt: { type: string, format: date-time }
      example:
        id: cmg1r2f3d0007q8b7h6j8k0zz
        paymentId: cmf9x1b2c0003q8b7h6j8k0lm
        amountBase: "1250"
        currency: USD
        decimals: 2
        reason: requested_by_customer
        note: Session moved to next week
        source: api
        status: succeeded
        actor: API key booking-server
        createdAt: "2026-09-21T10:00:04.000Z"

    RefundCheckoutPaymentResponse:
      description: The payment after the refund (see `CheckoutPayment`), plus the refund this call made.
      allOf:
        - $ref: "#/components/schemas/CheckoutPayment"
        - type: object
          required: [refund]
          properties:
            refund:
              oneOf:
                - $ref: "#/components/schemas/CheckoutRefund"
                - type: "null"

    CreateCheckoutProductRequest:
      type: object
      required: [name, currency, unitAmount]
      properties:
        name: { type: string, minLength: 1, maxLength: 200, examples: ["Consulting (60 min)"] }
        description: { type: [string, "null"], maxLength: 1000 }
        imageBase64:
          type: [string, "null"]
          maxLength: 700000
          pattern: '^data:image/(png|jpeg|jpg);base64,'
          description: "`data:image/png;base64,...` or `image/jpeg`, at most 700,000 characters (about 500KB). Shown on the payer page."
        currency:
          type: string
          enum: [USD, EUR, GBP, AED, MXN, BRL, ARS, INR, CNY, HKD, PHP, SGD, IDR, THB]
          examples: [USD]
        unitAmount:
          type: string
          pattern: '^\d{1,12}(\.\d{1,6})?$'
          description: Must be above zero.
          examples: ["150.00"]
      example:
        name: Consulting (60 min)
        currency: USD
        unitAmount: "150.00"

    UpdateCheckoutProductRequest:
      type: object
      description: "Every field optional. `imageBase64: null` clears the image."
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: [string, "null"], maxLength: 1000 }
        imageBase64: { type: [string, "null"] }
        currency:
          type: string
          enum: [USD, EUR, GBP, AED, MXN, BRL, ARS, INR, CNY, HKD, PHP, SGD, IDR, THB]
        unitAmount: { type: string, pattern: '^\d{1,12}(\.\d{1,6})?$' }
        archived:
          type: boolean
          description: True hides it from the picker and refuses new links from it. Existing links are untouched.
      example:
        archived: true

    CheckoutProduct:
      type: object
      required: [id, name, description, currency, unitAmount, archivedAt, linkCount, createdAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        currency: { type: string }
        unitAmount: { type: string }
        archivedAt: { type: [string, "null"], format: date-time }
        linkCount: { type: integer, description: "Links created from it." }
        createdAt: { type: string, format: date-time }
      example:
        id: 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d
        name: Consulting (60 min)
        description: null
        currency: USD
        unitAmount: "150.00"
        archivedAt: null
        linkCount: 0
        createdAt: "2026-09-12T09:55:00.000Z"

    # ─────────────────────────────────────────────────────── Webhooks ──

    WebhookCheckoutPayment:
      type: object
      description: |
        A payment arriving on one of your checkout links, as it appears inside a `checkout_payment.*` event: the body a website integration fulfills an
        order from. Money is decimal strings beside `currency`, the same
        convention as `WebhookPayout`.

        Join on `clientReferenceId` (the buyer visit's `?client_reference_id=`
        when the card page carried one, else the one you set on the link) and
        `metadata` (the link's, verbatim). Confirm with `amount` and
        `currency` against your own order before fulfilling; a buyer paid what
        the link asked, but your record of the order is yours to check.

        `paid` is not final: `checkout_payment.partially_refunded`,
        `checkout_payment.refunded` and `checkout_payment.reversed` (a
        chargeback) can follow, weeks later. On the two refund events `refund`
        is the piece that moved and `refunded` the cumulative figure; `status`
        stays `paid` on a partial. Not announced: a bank deposit held short of
        the total (it stays pending until the merchant accepts it in the
        dashboard), and an open dispute before it resolves.
      required: [paymentId, linkId, linkSlug, status, kind, amount, currency, createdAt, fee, net, refunded, paidAt, productId, clientReferenceId, metadata, failureCode, linkExpiredAt]
      properties:
        paymentId: { type: string }
        linkId:
          type: string
          description: "The checkout link: `GET /checkout/organizations/{orgId}/links/{linkId}`."
        linkSlug:
          type: string
          description: The link's public slug. It is the last path segment of `shareUrl` and the wire reference on a bank payment.
        productId:
          type: [string, "null"]
          description: The catalog product the link was made from, or null for an ad-hoc link.
        clientReferenceId:
          type: [string, "null"]
          description: The buyer visit's `client_reference_id` (card rail, from the link URL) if any, else the link's. Your join key.
        metadata:
          type: [object, "null"]
          additionalProperties: { type: string }
          description: The link's metadata, verbatim.
        status:
          type: string
          enum: [paid, failed, refunded, reversed]
        failureCode: { type: [string, "null"] }
        kind:
          type: string
          description: How the buyer paid, one of `card` (card, wallets and PayPal on the hosted page), `bank`, `crypto` or `cashapp`. Never the processor.
        amount:
          type: string
          description: Gross, a decimal string in `currency`.
        currency: { type: string }
        fee:
          type: string
          description: "Processing fee taken from the gross, a decimal string. `\"0.00\"` when none was stated."
        net:
          type: [string, "null"]
          description: What reached your balance, when the acceptor stated it.
        refunded:
          type: [string, "null"]
          description: Cumulative amount refunded, or null when nothing has been.
        paidAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        linkExpiredAt:
          type: [string, "null"]
          format: date-time
          description: |
            The link's `expiresAt` when this payment landed after it (a card
            session opened before the deadline and finished after; a wire that
            took days). The payment is `paid` either way;
            whether to honor it is your call. Null when the link had no
            deadline or the payment made it in time.
        refund:
          description: |
            Only on `checkout_payment.refunded` and
            `checkout_payment.partially_refunded`: the refund this event is
            about. Absent on every other type.
          type: object
          required: [id, amount, reason, note, source, at]
          properties:
            id: { type: string }
            amount: { type: string, description: "This refund, a decimal string in `currency`." }
            reason:
              oneOf:
                - $ref: "#/components/schemas/RefundReason"
                - type: "null"
            note: { type: [string, "null"] }
            source:
              type: string
              enum: [dashboard, api, acquirer]
            at: { type: string, format: date-time }
      example:
        paymentId: cmf9x1b2c0003q8b7h6j8k0lm
        linkId: 4f1c2a9e-8f7d-4c3b-9a2e-6b5d4c3f2a10
        linkSlug: 7e2a9c4b1d0f
        productId: 2a7b8c9d-1e2f-4a5b-8c9d-0e1f2a3b4c5d
        clientReferenceId: order_1042
        metadata: { plan: consulting-60min }
        status: paid
        failureCode: null
        kind: card
        amount: "150.00"
        currency: USD
        fee: "4.35"
        net: "145.65"
        refunded: null
        paidAt: "2026-09-12T10:00:04.000Z"
        createdAt: "2026-09-12T09:59:58.000Z"
        linkExpiredAt: null

    CheckoutWebhookEvent:
      type: object
      description: |
        A signed delivery carrying a checkout event. Verify over the raw bytes. Re-serializing a parsed object does not reproduce them, and one
        reordered key fails every signature.

        The envelope is the feed row: the same `id`, `sequence`, `type`,
        `createdAt`, `apiVersion` and `data` come back from
        `GET /payments/organizations/{orgId}/events` (Partner Payouts spec), so
        a delivery is the trigger to read the feed from the right place. The
        feed and the endpoint carry payout events too; branch on `type` and
        return 2xx for any you do not handle.
      required: [id, sequence, type, createdAt, apiVersion, livemode, data]
      properties:
        id:
          type: string
          description: The event id. Equals the `svix-id` header and the feed row's `id`. Dedupe on it.
        sequence:
          type: string
          description: The feed cursor for this event, as a decimal string. Pass it as `since` to read everything after it.
        type:
          type: string
          enum:
            - checkout_payment.paid
            - checkout_payment.failed
            - checkout_payment.refunded
            - checkout_payment.reversed
            - checkout_payment.partially_refunded
        createdAt: { type: string, format: date-time }
        apiVersion:
          type: integer
          description: Which body shape `data` is. Also sent as the `Avvio-Webhook-Version` header, so you can branch before parsing.
        livemode:
          type: boolean
          description: False when the payment was simulated in the sandbox; true on a live link.
        data: { $ref: "#/components/schemas/WebhookCheckoutPayment" }

  # Delivered to the endpoints you register on your organization, signed with
  # Standard Webhooks so any Svix-compatible verifier works.
  x-webhooks:
    checkout_payment:
      description: |
        Events: `checkout_payment.paid`, `checkout_payment.failed`,
        `checkout_payment.partially_refunded`, `checkout_payment.refunded`,
        `checkout_payment.reversed`. Money arriving on one of your checkout
        links: the payment landing, and the ways a paid payment stops being
        worth what it was. `partially_refunded` is the one that is not a
        status change: the payment stays `paid` and `refund` says what moved.

        **Opt-in.** An endpoint with an empty `events` list receives every
        payout-side type but not this family: name the types you want when
        you register the endpoint (`POST
        /organizations/{organizationId}/webhook-endpoints`, Partner Payouts
        spec). Endpoints registered before this family existed are unaffected.

        `checkout_payment.paid` fires when the money is in your balance at the
        acceptor (card: on capture; bank: when the deposit settles and covers
        the link; a payment you recorded by hand: immediately). One event per
        transition; `pending` and `processing` are never announced. `paid` is
        not final: `refunded` and `reversed` (a chargeback) can follow, weeks
        later.

        Headers: `svix-id` (the event id, stable across retries, equals the
        body's `id` and the feed row's `id`; dedupe on it), `svix-timestamp`,
        `svix-signature`, and `Avvio-Webhook-Version` (the `data` shape
        version, `1` today; equals the body's `apiVersion`). Signed content is
        `${svix-id}.${svix-timestamp}.${raw body}`, HMAC-SHA256 with the
        base64-decoded secret minus its `whsec_` prefix. Verify over the raw
        body, before parsing. Reject timestamps outside ±5 minutes. During a
        secret rotation `svix-signature` carries two signatures for 24 hours,
        one per secret; accept the delivery if either verifies.

        Body: `{ id, sequence, type, createdAt, apiVersion, livemode, data }`
        (`CheckoutWebhookEvent`). `data` is the payload below, verbatim: the same object the events feed returns for the same `id`.

        **Every state change is delivered, one event per transition**, written
        in the same transaction as the change. Delivery is at-least-once and
        in `sequence` order per endpoint. Retries after a failed attempt: 1m,
        5m, 15m, 1h, 3h, 6h, 12h, 24h, 24h: nine retries over roughly 67
        hours, each with ±20% jitter, then the delivery is dead and only the
        feed still has it. **Webhooks are the fast path, not the guarantee.**
        Reconcile from the events feed, and dedupe on `id`.
      payload:
        type: object
        properties:
          type:
            type: string
            enum:
              - checkout_payment.paid
              - checkout_payment.failed
              - checkout_payment.refunded
              - checkout_payment.reversed
              - checkout_payment.partially_refunded
          data: { $ref: "#/components/schemas/WebhookCheckoutPayment" }
