openapi: 3.1.1
info:
  title: Volume API
  version: 0.1.0
  summary: Hosted crypto checkout and invoices for businesses.
  description: |
    Volume lets a business take payment in crypto. Create an invoice, open a hosted
    checkout session for it, and Volume issues a deposit address, credits the payment
    once its custodian has accepted it, and tells you with a signed webhook.

    This specification is the contract: the API's routes and responses are tested
    against it.

    ## Test and live

    The API key selects the mode: `vk_test_…` payments are simulated and `vk_live_…`
    ones are real. Test and live data, endpoints and events are fully isolated. Pay a
    test checkout from its page, or with
    `POST /v1/test_helpers/checkout_sessions/{id}/pay`, and the same events follow as
    for a real payment.

    ## Idempotency

    `POST /v1/api_keys`, `POST /v1/invoices`, `POST /v1/checkout_sessions` and
    `POST /v1/payouts` take an `Idempotency-Key`. Retrying with the same key replays the original response, marked
    `Idempotent-Replayed: true`, instead of doing it twice. The API key replay leaves
    out the key itself, which is shown once and stored nowhere.

    ## Webhooks

    Register an endpoint per mode (`POST /v1/webhook_endpoints?mode=`) and keep its
    `whsec_…` secret. Volume POSTs each event as JSON (the `Event` schema) and signs
    it:

    ```
    Volume-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
    ```

    Verify it against the raw body before parsing, and refuse a timestamp more than
    five minutes off:

    ```js
    import { createHmac, timingSafeEqual } from 'node:crypto';

    export function verifySignature(secret, rawBody, header, now = Date.now()) {
      const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
      const t = Number(parts.t);
      const expected = createHmac('sha256', secret)
        .update(`${t}.${rawBody}`)
        .digest('hex');
      const given = Buffer.from(parts.v1 ?? '');
      const valid =
        given.length === expected.length &&
        timingSafeEqual(given, Buffer.from(expected));
      return valid && Math.abs(now / 1000 - t) <= 300;
    }
    ```

    Answer `2xx` within ten seconds; anything else, or no answer, is tried again with
    backoff for three days. Delivery is at least once and not in order: dedupe on the
    event `id`, and read the state you need from the event, or fetch it. Grant access
    on `invoice.paid`. `POST /v1/webhook_endpoints/{id}/test` sends a test event.

    ## Payouts

    Every payment credits the merchant's balance, in dollars, less Volume's fee. With
    auto-payout on (the default), each one is then paid out to the default destination:
    for now, a Nigerian bank account, in naira. Destinations and payout settings are
    managed while logged in, never with an API key, so a leaked key cannot send money
    anywhere new.

    A bank account is added by bank code (`GET /v1/banks`) and account number; its
    holder's name is read from the bank. In live mode it must match the business's
    verified legal name. A merchant's first destination can be paid at once. Any later
    one only 48 hours after it is added, Saturdays and Sundays in Lagos not counted,
    and every user of the account is emailed when one is added.

    ## Rate limits

    Requests are counted per credential, and per address where there is none. Logging
    in, signing up and verifying codes have tight limits. A refusal is
    `429 too_many_requests` with a `Retry-After` header.

    ## Errors

    Every error response is `{ "error": { "type", "code", "message", "request_id" } }`.
    The `type` is a stable category; `code` is the specific reason.

    ## Request ids

    Every response carries a `Request-Id` header, which also appears as `request_id` in
    error bodies. Quote it when reporting a problem.
servers:
  - url: https://api.paywithvolume.com
    description: Production. Mode comes from the API key, not the URL.
security:
  - ApiKey: []
tags:
  - name: Account
    description: The merchant a login or API key acts for.
  - name: Webhooks
    description: Where Volume delivers a merchant's events, signed; see "Webhooks" above.
  - name: Payments
    description: Invoices, and the hosted checkouts that collect them.
  - name: Test helpers
    description: Test mode only. Simulate what a payer does, for your own tests.
paths:
  /v1/account:
    get:
      operationId: getAccount
      summary: The account
      description: The merchant a session or API key acts for, and its status.
      tags:
        - Account
      security:
        - ApiKey: []
      responses:
        "200":
          description: The account.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/webhook_endpoints:
    get:
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      description: The endpoints registered in this mode, deleted ones aside, newest first.
      tags:
        - Webhooks
      security:
        - ApiKey: []
      parameters: []
      responses:
        "200":
          description: The endpoints.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    const: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookEndpoint"
                required:
                  - object
                  - data
                additionalProperties: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createWebhookEndpoint
      summary: Register a webhook endpoint
      description: |
        Registers the https URL that receives events in one mode, and returns its
        signing secret this once. Deliveries are signed
        `Volume-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`. A
        live endpoint needs an active account. One registration per URL.

        With an API key, in the key's mode: an integration can register its own
        endpoint when it deploys. Logged in, name the mode.
      tags:
        - Webhooks
      security:
        - ApiKey: []
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                enabled_events:
                  type: array
                  description: Event types to deliver, or `['*']` (the default) for all.
                  items:
                    type: string
                  minItems: 1
                description:
                  type: string
                  maxLength: 500
              required:
                - url
              additionalProperties: false
      responses:
        "201":
          description: The endpoint, with its signing secret this once.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/webhook_endpoints/{webhook_endpoint_id}:
    delete:
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      description: |
        Nothing more is sent to it, and deliveries waiting to retry are cancelled.
        Answers with the endpoint, its status `deleted`; it is not found after that.
      tags:
        - Webhooks
      security:
        - ApiKey: []
      parameters:
        - name: webhook_endpoint_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The endpoint, deleted.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpoint"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/webhook_endpoints/{webhook_endpoint_id}/test:
    post:
      operationId: sendTestWebhook
      summary: Send a test event
      description: |
        Sends the endpoint a `webhook_endpoint.test` event, whatever it subscribes to,
        signed and delivered like any other. The answer is the event; its delivery is
        queued and follows within seconds.
      tags:
        - Webhooks
      security:
        - ApiKey: []
      parameters:
        - name: webhook_endpoint_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "202":
          description: The test event, queued for delivery.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Event"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/webhook_deliveries:
    get:
      operationId: listWebhookDeliveries
      summary: List webhook deliveries
      description: |
        Every event sent to the account's endpoints in this mode and how each endpoint
        answered, newest first, to debug an integration.
      tags:
        - Webhooks
      security:
        - ApiKey: []
      parameters:
        - name: webhook_endpoint
          in: query
          required: false
          description: Only deliveries to this endpoint.
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - pending
              - succeeded
              - failed
              - cancelled
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/PageCursor"
      responses:
        "200":
          description: The deliveries.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    const: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDelivery"
                  has_more:
                    type: boolean
                  next_cursor:
                    type:
                      - string
                      - "null"
                required:
                  - object
                  - data
                  - has_more
                  - next_cursor
                additionalProperties: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/invoices:
    post:
      operationId: createInvoice
      summary: Create an invoice
      description: |
        A one-off USD invoice, in the mode of the API key. Open a checkout session for it
        to collect payment. At most $100,000.00. Lightning carries at most
        $1,000.00, so a checkout above that does not offer it.
      tags:
        - Payments
      security:
        - ApiKey: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:
                  type: string
                  pattern: ^(0|[1-9][0-9]{0,8})(\.[0-9]{1,2})?$
                  description: USD as a decimal string, such as `25.00`.
                description:
                  type: string
                  maxLength: 500
                customer_email:
                  type: string
                  format: email
                  description: The payer, when the invoice is for someone in particular.
                metadata:
                  $ref: "#/components/schemas/Metadata"
              required:
                - amount
              additionalProperties: false
      responses:
        "201":
          description: The invoice.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/invoices/{invoice_id}:
    get:
      operationId: getInvoice
      summary: Get an invoice
      description: The invoice, its status, and every transfer that paid towards it.
      tags:
        - Payments
      security:
        - ApiKey: []
      parameters:
        - name: invoice_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The invoice.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/checkout_sessions:
    get:
      operationId: listCheckoutSessions
      summary: List checkout sessions
      description: |
        The account's checkouts, newest first. With an API key, those of its mode; when
        logged in, name the mode.
      tags:
        - Payments
      security:
        - ApiKey: []
      parameters:
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/CheckoutSessionStatus"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/PageCursor"
      responses:
        "200":
          description: The checkout sessions.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    const: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CheckoutSession"
                  has_more:
                    type: boolean
                  next_cursor:
                    type:
                      - string
                      - "null"
                required:
                  - object
                  - data
                  - has_more
                  - next_cursor
                additionalProperties: false
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
    post:
      operationId: createCheckoutSession
      summary: Create a checkout session
      description: |
        A hosted checkout for an open invoice. Send the payer to its `url`
        (`paywithvolume.com/{id}`); it is open for 24 hours, and once the payer picks a
        coin they have 30 minutes to pay. Return URLs must be https in live mode.
      tags:
        - Payments
      security:
        - ApiKey: []
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                invoice_id:
                  type: string
                customer_email:
                  type: string
                  format: email
                  description: Required unless the invoice names its customer.
                success_url:
                  type: string
                  format: uri
                cancel_url:
                  type: string
                  format: uri
              required:
                - invoice_id
                - success_url
                - cancel_url
              additionalProperties: false
      responses:
        "201":
          description: The checkout session.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/checkout_sessions/{checkout_session_id}:
    get:
      operationId: getCheckoutSession
      summary: Get a checkout session
      description: |
        Its status, the payer's choice of coin and network, and what has arrived. With
        an API key, in its mode; when logged in, name the mode.
      tags:
        - Payments
      security:
        - ApiKey: []
      parameters:
        - name: checkout_session_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The checkout session.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
  /v1/test_helpers/checkout_sessions/{checkout_session_id}/pay:
    post:
      operationId: simulateCheckoutPayment
      summary: Pay a test checkout by simulation
      description: |
        Test keys only. Once the payer has chosen a coin and network, a simulated
        payment arrives at the checkout's address at once and is accepted about ten
        seconds later, through the same path as a real one: the checkout's status,
        `payment.confirmed`, `invoice.paid` (with `invoice.overpaid` when over, or
        `invoice.underpaid` instead when short) and the merchant's balance all follow
        as they would live. `exact` sends what is still due, `short` a tenth less and
        `over` a tenth more, both past the tolerance (up to 1% or $0.50 short,
        whichever is smaller, still counts as paid). A Lightning invoice is paid
        exactly only. No money moves. A live key is refused with `403 test_mode_only`.
      tags:
        - Test helpers
      security:
        - ApiKey: []
      parameters:
        - $ref: "#/components/parameters/CheckoutSessionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TestPaymentRequest"
      responses:
        "202":
          description: The payment is on its way; the checkout as it stands.
          headers:
            Request-Id:
              $ref: "#/components/headers/RequestId"
            Idempotent-Replayed:
              $ref: "#/components/headers/IdempotentReplayed"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckoutSession"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
webhooks:
  event:
    post:
      operationId: receiveEvent
      summary: An event, delivered to your endpoint
      description: |
        Each event is POSTed to every active endpoint of yours, in its mode, that
        subscribes to its type. Verify `Volume-Signature` first (see "Webhooks"), answer
        `2xx` within ten seconds, and dedupe on `id`. Anything but `2xx` is tried again
        with backoff for three days.
      parameters:
        - name: Volume-Signature
          in: header
          required: true
          description: '`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`'
          schema:
            type: string
        - name: Volume-Event-Id
          in: header
          required: true
          schema:
            type: string
        - name: Volume-Event-Type
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Event"
      responses:
        "200":
          description: Any `2xx` means received.
      tags:
        - Webhooks
components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: |
        A merchant API key, sent as `Authorization: Bearer vk_live_…`. The prefix
        selects the mode: `vk_test_` for testnets, `vk_live_` for real money.
  headers:
    RequestId:
      description: Unique id for this request, for support and log correlation.
      required: true
      schema:
        type: string
        pattern: ^req_[0-9a-f]{32}$
    IdempotentReplayed:
      description: Present, with `true`, when this is a replay of an earlier request.
      required: false
      schema:
        const: "true"
    RetryAfter:
      description: |
        Whole seconds to wait before retrying, when waiting will help. A sign-up code
        that has had too many wrong guesses has none: ask for a new code instead.
      required: false
      schema:
        type: string
        pattern: ^[0-9]+$
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Makes the request safe to retry: the same key replays the first response
        instead of repeating the effect. At most 255 characters.
      schema:
        type: string
        minLength: 1
        maxLength: 255
    CheckoutSessionId:
      name: checkout_session_id
      in: path
      required: true
      schema:
        type: string
    PageLimit:
      name: limit
      in: query
      required: false
      description: How many to return, newest first.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    PageCursor:
      name: cursor
      in: query
      required: false
      description: The `next_cursor` of the page before, for the page after it.
      schema:
        type: string
  schemas:
    TestPaymentRequest:
      type: object
      properties:
        outcome:
          type: string
          enum:
            - exact
            - short
            - over
          default: exact
          description: |
            What the simulated payer sends: what is still due, a tenth less, or a tenth
            more. Once nothing is due, `exact` pays it all again.
      additionalProperties: false
    CheckoutTheme:
      type: string
      enum:
        - dark
        - light
        - auto
      description: |
        How the hosted checkout looks, for every payer: `dark` (the default), `light`,
        or `auto`, which follows the payer's device.
    Account:
      type: object
      properties:
        object:
          const: account
        id:
          type: string
          pattern: ^mer_[0-9A-Za-z]{24}$
        business_name:
          type: string
        legal_name:
          type:
            - string
            - "null"
          description: The registered name, set when Volume activates the account.
        status:
          type: string
          enum:
            - pending
            - active
            - suspended
        live_access:
          type: boolean
          description: Whether live keys and live payments are open to this account.
        fee_bps:
          type: integer
          minimum: 0
          maximum: 10000
          description: |
            Volume's fee on each payment credited to the account, in basis points (100
            is 1%), rounded down to the micro-dollar.
        activated_at:
          type:
            - string
            - "null"
          format: date-time
        checkout_theme:
          $ref: "#/components/schemas/CheckoutTheme"
        created_at:
          type: string
          format: date-time
      required:
        - object
        - id
        - business_name
        - legal_name
        - status
        - live_access
        - fee_bps
        - activated_at
        - checkout_theme
        - created_at
      additionalProperties: false
    WebhookEventType:
      type: string
      enum:
        - checkout.payment_detected
        - payment.confirmed
        - invoice.paid
        - invoice.underpaid
        - invoice.overpaid
        - invoice.expired
        - payout.sent
        - payout.failed
    WebhookEndpoint:
      type: object
      properties:
        object:
          const: webhook_endpoint
        id:
          type: string
          pattern: ^whe_[0-9A-Za-z]{24}$
        livemode:
          type: boolean
        url:
          type: string
        description:
          type:
            - string
            - "null"
        enabled_events:
          type: array
          items:
            anyOf:
              - $ref: "#/components/schemas/WebhookEventType"
              - const: "*"
        status:
          type: string
          enum:
            - active
            - disabled
            - deleted
        created_at:
          type: string
          format: date-time
        secret:
          type: string
          pattern: ^whsec_[A-Za-z0-9_-]{43}$
          description: The signing secret. Only in the registration response.
      required:
        - object
        - id
        - livemode
        - url
        - description
        - enabled_events
        - status
        - created_at
      additionalProperties: false
    WebhookDelivery:
      type: object
      description: One event on its way to one endpoint, and how the endpoint answered.
      properties:
        object:
          const: webhook_delivery
        id:
          type: string
          pattern: ^whd_[0-9A-Za-z]{24}$
        livemode:
          type: boolean
        event:
          type: object
          properties:
            id:
              type: string
              pattern: ^evt_[0-9A-Za-z]{24}$
            type:
              type: string
            created_at:
              type: string
              format: date-time
          required:
            - id
            - type
            - created_at
          additionalProperties: false
        webhook_endpoint:
          type: object
          properties:
            id:
              type: string
              pattern: ^whe_[0-9A-Za-z]{24}$
            url:
              type: string
          required:
            - id
            - url
          additionalProperties: false
        status:
          type: string
          enum:
            - pending
            - succeeded
            - failed
            - cancelled
          description: |
            `pending` until the endpoint answers 2xx (`succeeded`) or three days of
            retries run out (`failed`); `cancelled` when the endpoint was disabled or
            deleted first.
        attempts:
          type: integer
          minimum: 0
        last_status_code:
          type:
            - integer
            - "null"
          description: What the endpoint last answered; null when nothing answered.
        last_error:
          type:
            - string
            - "null"
        completed_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
      required:
        - object
        - id
        - livemode
        - event
        - webhook_endpoint
        - status
        - attempts
        - last_status_code
        - last_error
        - completed_at
        - created_at
      additionalProperties: false
    Metadata:
      type: object
      description: Up to 20 keys of your own, each up to 40 characters, values up to 500.
      maxProperties: 20
      propertyNames:
        minLength: 1
        maxLength: 40
      additionalProperties:
        type: string
        maxLength: 500
    PaymentStatus:
      type: string
      enum:
        - detected
        - confirmed
        - dropped
        - rejected
      description: |
        `confirmed` once the custodian has accepted it, and only then; `dropped` when a
        reorganisation removed it, `rejected` when the custodian refused it.
    Payment:
      type: object
      properties:
        object:
          const: payment
        id:
          type: string
          pattern: ^pay_[0-9A-Za-z]{24}$
        checkout_session:
          type: string
          pattern: ^cs_[0-9A-Za-z]{24}$
        asset:
          type: string
          enum:
            - USDT
            - USDC
            - BTC
        network:
          type: string
          enum:
            - tron
            - bsc
            - base
            - solana
            - bitcoin
            - lightning
        amount:
          type: string
          description: In the coin, as a decimal string.
        status:
          $ref: "#/components/schemas/PaymentStatus"
        tx_hash:
          type:
            - string
            - "null"
          description: Null for a transfer from another account at the custodian.
        detected_at:
          type: string
          format: date-time
        confirmed_at:
          type:
            - string
            - "null"
          format: date-time
      required:
        - object
        - id
        - checkout_session
        - asset
        - network
        - amount
        - status
        - tx_hash
        - detected_at
        - confirmed_at
      additionalProperties: false
    Invoice:
      type: object
      properties:
        object:
          const: invoice
        id:
          type: string
          pattern: ^inv_[0-9A-Za-z]{24}$
        livemode:
          type: boolean
        status:
          type: string
          enum:
            - open
            - paid
            - void
        amount:
          type: string
          description: USD, as a decimal string.
        currency:
          const: usd
        description:
          type:
            - string
            - "null"
        customer:
          type:
            - string
            - "null"
        customer_email:
          type:
            - string
            - "null"
        metadata:
          $ref: "#/components/schemas/Metadata"
        payments:
          type: array
          items:
            $ref: "#/components/schemas/Payment"
        paid_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
      required:
        - object
        - id
        - livemode
        - status
        - amount
        - currency
        - description
        - customer
        - customer_email
        - metadata
        - payments
        - paid_at
        - created_at
      additionalProperties: false
    PaymentChoice:
      type: object
      description: What the payer chose, and what they were asked to send.
      properties:
        source:
          type: string
          description: Where they pay from, such as `bybit`.
        asset:
          type: string
          enum:
            - USDT
            - USDC
            - BTC
        network:
          type: string
          enum:
            - tron
            - bsc
            - base
            - solana
            - bitcoin
            - lightning
        amount_due:
          type: string
          description: What must arrive, in the coin.
        withdraw_amount:
          type:
            - string
            - "null"
          description: What an exchange payer withdraws, the amount due plus the exchange's fee.
        rate:
          type: string
          description: USD per whole coin when the address was issued.
        expires_at:
          type: string
          format: date-time
          description: The end of the payer's 30 minutes.
      required:
        - source
        - asset
        - network
        - amount_due
        - withdraw_amount
        - rate
        - expires_at
      additionalProperties: false
    CheckoutSessionStatus:
      type: string
      enum:
        - open
        - awaiting_payment
        - detected
        - confirming
        - paid
        - underpaid
        - overpaid
        - expired
        - refund_due
        - settled
    CheckoutSession:
      type: object
      properties:
        object:
          const: checkout_session
        id:
          type: string
          pattern: ^cs_[0-9A-Za-z]{24}$
        livemode:
          type: boolean
        url:
          type: string
          format: uri
          description: The hosted checkout, `paywithvolume.com/{id}`.
        status:
          $ref: "#/components/schemas/CheckoutSessionStatus"
        invoice:
          type: string
        customer:
          type: string
        customer_email:
          type: string
        amount:
          type: string
          description: USD, as a decimal string.
        currency:
          const: usd
        description:
          type:
            - string
            - "null"
          description: The invoice's description, what is being paid for.
        payment:
          oneOf:
            - $ref: "#/components/schemas/PaymentChoice"
            - type: "null"
        amount_received:
          type:
            - string
            - "null"
          description: What has counted so far, in the chosen coin.
        payments:
          type: array
          items:
            $ref: "#/components/schemas/Payment"
        success_url:
          type: string
        cancel_url:
          type: string
        expires_at:
          type: string
          format: date-time
        paid_at:
          type:
            - string
            - "null"
          format: date-time
        created_at:
          type: string
          format: date-time
      required:
        - object
        - id
        - livemode
        - url
        - status
        - invoice
        - customer
        - customer_email
        - amount
        - currency
        - description
        - payment
        - amount_received
        - payments
        - success_url
        - cancel_url
        - expires_at
        - paid_at
        - created_at
      additionalProperties: false
    Event:
      type: object
      description: |
        What an endpoint receives, and the test event's answer. `data` holds the
        resources the event is about, as they were when it happened:
        `checkout_session` for every payment event, with `payment` for
        `checkout.payment_detected` and `payment.confirmed` (and `credited_usd`,
        `fee_usd` on the latter), `amount_received` with `amount_remaining` or
        `amount_excess` for `invoice.underpaid` and `invoice.overpaid`, and
        `webhook_endpoint` for `webhook_endpoint.test`.
      properties:
        id:
          type: string
          pattern: ^evt_[0-9A-Za-z]{24}$
          description: Unique per event; the same on every delivery of it. Dedupe on it.
        object:
          const: event
        type:
          type: string
          description: A `WebhookEventType`, or `webhook_endpoint.test`.
        created_at:
          type: string
          format: date-time
        livemode:
          type: boolean
        data:
          type: object
      required:
        - id
        - object
        - type
        - created_at
        - livemode
        - data
      additionalProperties: false
    ErrorType:
      type: string
      description: Stable error category.
      enum:
        - invalid_request_error
        - authentication_error
        - payment_required
        - permission_error
        - not_found
        - conflict
        - unprocessable
        - rate_limit_error
        - api_error
        - upstream_error
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type:
              $ref: "#/components/schemas/ErrorType"
            code:
              type: string
              description: The specific reason, stable per `type`.
              examples:
                - not_found
            message:
              type: string
              description: Human-readable explanation. Not for programmatic use.
            request_id:
              type: string
              pattern: ^req_[0-9a-f]{32}$
            retry_after:
              type: integer
              description: |
                Seconds to wait before retrying, on `too_many_requests` and
                `too_many_attempts`. The same value as the `Retry-After` header.
          required:
            - type
            - code
            - message
            - request_id
      required:
        - error
  responses:
    BadRequest:
      description: |
        The request is malformed: `invalid_request`, `unknown_parameter`,
        `invalid_query_parameter`, `idempotency_key_required`,
        `invalid_idempotency_key`, `invalid_code`, `code_expired`,
        `signup_not_verified`, `invalid_webhook_url`, `unknown_event_type`,
        `invalid_amount`, `amount_too_large`, `invalid_url`,
        `customer_email_required`, `customer_mismatch`,
        `unsupported_payment_method`, `exact_amount_only`.
      headers:
        Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: |
        No valid credential: `invalid_credentials`, `invalid_session`,
        `invalid_api_key`, `invalid_signature`.
      headers:
        Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: |
        Not allowed for this account: `account_suspended` (a suspended account may
        only read), `live_access_required` (live needs an active account),
        `test_mode_only` (a simulated payment in live mode), `not_permitted` (a staff
        member's role does not allow it), `step_up_required` (an authenticator code is
        needed first: `POST /v1/auth/step_up`, or `POST /admin/auth/step_up` for
        staff), `authenticator_required` (a live money action needs an authenticator
        set up first: `POST /v1/auth/authenticator`).
      headers:
        Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: |
        Clashes with what exists: `email_taken`, `webhook_endpoint_exists`,
        `idempotency_key_reuse_mismatch`, `invoice_not_open`,
        `checkout_session_closed`, `webhook_endpoint_disabled`,
        `legal_name_required`, `payout_destination_not_usable`,
        `payout_destination_required`, `insufficient_balance`, `balance_changed`,
        `no_payment_method`, `invoice_closed`, `staff_exists`, `not_on_yourself`
        (nobody changes their own role, status or authenticator), `last_super_admin`.
      headers:
        Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    TooManyRequests:
      description: |
        Slow down: `too_many_requests` (a rate limit) or `too_many_attempts` (too many
        wrong codes or passwords).
      headers:
        Request-Id:
          $ref: "#/components/headers/RequestId"
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: |
        No such resource, or not yours: `not_found`, `api_key_not_found`,
        `merchant_not_found`, `invoice_not_found`, `checkout_session_not_found`,
        `webhook_endpoint_not_found`, `payout_destination_not_found`,
        `payout_not_found`, `flag_not_found`, `staff_not_found`.
      headers:
        Request-Id:
          $ref: "#/components/headers/RequestId"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
