openapi: 3.1.0
info:
  title: Crypto Pay API
  version: "2026-08-11"
  summary: Merchant payments API of the tgpay crypto wallet (@tgpaycryptobot).
  description: |
    Crypto Pay is the app-facing payments API of the tgpay crypto wallet:
    create invoices, refund them, send payouts and checks, run recurring
    subscriptions, and receive signed webhooks.

    The API is **Crypto Bot-compatible**: same method names, the same
    `{ok, result}` envelope, decimal-string amounts, the same webhook
    signature scheme. An existing Crypto Bot integration can usually be
    pointed at this API by changing only the base URL and the token.
    Extensions over the Crypto Bot contract are marked **Extension** in the
    descriptions.

    Human-readable documentation: https://help.tgpaybot.com/crypto-pay-api-reference/

    ## Conventions

    - **Authentication** — send your token in the `TgCryptoPay-API-Token`
      header (canonical). `Crypto-Pay-API-Token` is accepted as a
      compatibility alias; if both are sent, the canonical header wins.
    - **Envelope** — success is `{"ok": true, "result": …}` with HTTP 200;
      errors are `{"ok": false, "error": {"code": <HTTP status>, "name":
      "<machine_name>"}}` with the same HTTP status on the wire. Branch on
      `error.name`.
    - **Amounts** — crypto amounts are decimal **strings** in whole-coin
      units (`"10.5"`), never JSON numbers. Response objects also carry
      `*_minor` fields: the integer minor-unit amount **as a string**
      (wei-scale values overflow the JS `Number` type). Per-asset decimals
      come from `getCurrencies` — drive amount math from there.
    - **POST parameters** — a JSON object body, a form-urlencoded body, or
      URL query parameters (merged; body wins). `multipart/form-data` is
      not supported. Money-moving methods are POST-only (no GET aliases).
    - **Idempotency** — money-moving methods take a caller-supplied
      `spend_id` (required on `transfer`/`transferBatch` items, optional on
      `createCheck`/`refundInvoice`). A retry with the same key and the
      same parameters replays the stored result; the same key with
      different parameters returns `409 idempotency_conflict`; a retry
      while the original is still executing returns
      `409 idempotency_in_progress`.
    - **Pagination** — list methods take `offset` (default 0) and `count`
      (default 100, max 1000; `getSubscriptions`: max 500); ordering is
      always newest first. Lists come back as `{"items": […]}`.
    - **Timestamps** — ISO-8601 strings with a UTC offset, or `null`.
    - **Rate limits** — per app, across all its tokens: `createInvoice`
      60/min, `createCheck` 60/min, `refundInvoice` 30/min, `transfer`
      30/min, `transferBatch` 10/min. Exceeding returns
      `429 rate_limited`.
    - **Maintenance** — during platform maintenance every POST method
      returns `503 maintenance`; GET methods keep working.

    ## Errors common to every method

    | HTTP | `error.name` | when |
    |---|---|---|
    | 401 | `unauthorized` | missing/invalid/revoked token, deactivated app |
    | 403 | `scope_required` | restricted token lacking the method's scope |
    | 400 | `invalid_request` | unparsable/invalid body or parameters (POST) |
    | 429 | `rate_limited` | rate limit exceeded (rate-limited methods) |
    | 503 | `maintenance` | maintenance mode (POST methods) |
    | 500 | `internal_error` | unexpected server error |

    Each operation below lists only its *specific* errors.
externalDocs:
  description: Crypto Pay API reference (help center)
  url: https://help.tgpaybot.com/crypto-pay-api-reference/
servers:
  - url: https://crypto.tgpaybot.com/pay/api
security:
  - apiToken: []
  - apiTokenCompat: []
tags:
  - name: app
    description: App identity, balance, currencies, rates, stats.
  - name: invoices
    description: Payment requests and refunds.
  - name: checks
    description: Single-use claimable crypto checks funded from the app balance.
  - name: transfers
    description: Payouts to Telegram users from the app balance.
  - name: subscriptions
    description: "Extension: recurring billing — plans and subscriptions."

paths:
  /getMe:
    get:
      tags: [app]
      operationId: getMe
      summary: App and token info
      description: |
        Scope: none — any valid token.

        Returns the app's identity, webhook configuration, and — for a
        restricted token — the token's scopes and label (`scopes` is `null`
        for the full-access primary token). Note: unlike Crypto Bot,
        `app_id` is a string.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/AppInfo" }
        default: { $ref: "#/components/responses/Error" }

  /getBalance:
    get:
      tags: [app]
      operationId: getBalance
      summary: App balances per asset
      description: |
        Scope: `read`.

        One row per platform-enabled asset (rows are present even at zero
        balance). `onhold` is the amount locked in the app's un-activated
        checks — refundable via `deleteCheck`, not spendable.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: array
                    items: { $ref: "#/components/schemas/BalanceRow" }
        default: { $ref: "#/components/responses/Error" }

  /getCurrencies:
    get:
      tags: [app]
      operationId: getCurrencies
      summary: Supported currencies
      description: |
        Scope: none — any valid token.

        The authoritative list of crypto assets (`is_blockchain: true`) and
        invoice-pricing fiats (`is_fiat: true`), with per-currency
        `decimals`. Treat this as the source of truth for what's live and
        for amount math.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: array
                    items: { $ref: "#/components/schemas/Currency" }
        default: { $ref: "#/components/responses/Error" }

  /getExchangeRates:
    get:
      tags: [app]
      operationId: getExchangeRates
      summary: Crypto→fiat exchange rates
      description: |
        Scope: none — any valid token.

        `is_valid` is a whole-table property: `true` when the rates come
        from a recent successful fetch, `false` for a degraded/stale cache.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: array
                    items: { $ref: "#/components/schemas/ExchangeRate" }
        default: { $ref: "#/components/responses/Error" }

  /getStats:
    get:
      tags: [app]
      operationId: getStats
      summary: App volume/conversion stats
      description: |
        Scope: `read`.

        Defaults to the last 24 hours. Naive datetimes are read as UTC; a
        `Z` suffix is accepted.

        Specific errors: `400 invalid_date` (unparsable `start_at`/`end_at`).
      parameters:
        - name: start_at
          in: query
          schema: { type: string, format: date-time }
          description: Window start (default now − 24 h).
        - name: end_at
          in: query
          schema: { type: string, format: date-time }
          description: Window end (default now).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/AppStats" }
        default: { $ref: "#/components/responses/Error" }

  /createInvoice:
    post:
      tags: [invoices]
      operationId: createInvoice
      summary: Create an invoice
      description: |
        Scope: `invoices`. Rate limit: 60/min per app.

        Crypto mode (default) fixes `asset`; fiat mode fixes `fiat` and the
        payer picks one of `accepted_assets` at pay time. Invoices are paid
        from the payer's in-app wallet balance; a payer may also fund the
        invoice from an external wallet (their deposit lands in their own
        wallet and the invoice auto-settles) — the merchant sees a normal
        `paid` invoice either way. The platform fee comes out of the
        merchant's leg and is reported in `fee_amount`.

        Fiat invoices convert at the fresh pay-time rate rounded up in the
        merchant's favor — unless a live `rate_lock_seconds` stamp applies.
        If no fresh rate is available, payment fails payer-side; the
        merchant is never settled at a stale rate.

        Specific errors:

        | HTTP | `error.name` | when |
        |---|---|---|
        | 400 | `invalid_currency` | `asset`+`fiat` together; fiat mode without `fiat`; `open_amount` on a fiat invoice |
        | 400 | `invalid_amount` | missing/unparsable/non-positive amount; `amount`+`open_amount` together; fiat amount over 2 decimal places or > 10^13 |
        | 404 | `unknown_asset` | unknown/disabled `asset`, `swap_to`, or `accepted_assets` entry |
        | 400 | `unsupported_fiat` | `fiat` not supported |
        | 400 | `paid_btn_url_required` | `paid_btn_name` set without a valid http(s) `paid_btn_url` |
        | 400 | `rate_lock_fiat_only` | `rate_lock_seconds` on a crypto-priced invoice |
        | 409 | `ratelock_disabled` | rate-lock currently unavailable platform-side |
        | 409 | `rate_unavailable` | no fresh rate for an accepted asset — retry |
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateInvoiceRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/CreateInvoiceRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Invoice" }
        default: { $ref: "#/components/responses/Error" }

  /getInvoices:
    get:
      tags: [invoices]
      operationId: getInvoices
      summary: List invoices
      description: |
        Scope: `read`. Newest first.

        `status=active` excludes invoices already past their deadline;
        `status=expired` (a quiet extension — Crypto Bot only knows
        active/paid) includes them.
      parameters:
        - { name: asset, in: query, schema: { type: string }, description: Filter by crypto asset code. }
        - { name: fiat, in: query, schema: { type: string }, description: Filter by fiat code. }
        - { name: invoice_ids, in: query, schema: { type: string }, description: Comma-separated invoice ids. }
        - { name: status, in: query, schema: { type: string, enum: [active, paid, expired] } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: count, in: query, schema: { type: integer, default: 100, minimum: 1, maximum: 1000 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/Invoice" }
        default: { $ref: "#/components/responses/Error" }

  /deleteInvoice:
    post:
      tags: [invoices]
      operationId: deleteInvoice
      summary: Delete an unpaid invoice
      description: |
        Scope: `invoices`.

        Cancels an **unpaid** invoice (active or expired). Result: `true`.

        Specific errors: `404 invoice_not_found` (unknown id or another
        app's invoice); `409 invoice_already_paid` (paid invoices can't be
        deleted — money moved).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_id]
              properties:
                invoice_id: { type: integer, minimum: 1 }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [invoice_id]
              properties:
                invoice_id: { type: integer, minimum: 1 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { const: true }
        default: { $ref: "#/components/responses/Error" }

  /refundInvoice:
    post:
      tags: [invoices]
      operationId: refundInvoice
      summary: "Refund a paid invoice (extension)"
      description: |
        **Extension over Crypto Bot.** Scope: `refunds`. Rate limit: 30/min
        per app.

        Returns a paid invoice's face amount — or part of it — from the app
        balance to the payer, anonymous payers included, without revealing
        them. The platform fee is not returned. Invoice `status` stays
        `paid`; progress is tracked in the cumulative `refunded_amount` /
        `refunded_minor`, and `refunded_at` is stamped once fully refunded.
        Triggers the opt-in `refund_completed` webhook.

        Specific errors:

        | HTTP | `error.name` | when |
        |---|---|---|
        | 404 | `invoice_not_found` | unknown id / another app's invoice |
        | 400 | `invalid_amount` | unparsable or non-positive `amount` |
        | 409 | `invoice_not_paid` | invoice isn't `paid` |
        | 409 | `already_refunded` | nothing left to refund |
        | 409 | `amount_too_big` | `amount` exceeds the unrefunded remainder |
        | 409 | `insufficient_funds` | app balance can't cover the refund |
        | 409 | `idempotency_conflict` / `idempotency_in_progress` | see Conventions → Idempotency |
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundInvoiceRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/RefundInvoiceRequest" }
      responses:
        "200":
          description: OK — the updated invoice with cumulative `refunded_*` fields.
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Invoice" }
        default: { $ref: "#/components/responses/Error" }

  /createCheck:
    post:
      tags: [checks]
      operationId: createCheck
      summary: Create a check
      description: |
        Scope: `checks`. Rate limit: 60/min per app.

        Creates a single-use crypto check funded from the app balance;
        anyone with the link (or the pinned user) can claim it into their
        in-app wallet. The amount is locked immediately (moves from
        `available` to `onhold` in `getBalance`). Claiming fires the opt-in
        `check_activated` webhook.

        `spend_id` idempotency is an extension — Crypto Bot's createCheck
        moves money with no idempotency at all.

        Specific errors:

        | HTTP | `error.name` | when |
        |---|---|---|
        | 404 | `unknown_asset` | unknown/disabled asset |
        | 400 | `invalid_amount` | unparsable/non-positive amount |
        | 404 | `user_not_found` | `pin_to_username` matches no user |
        | 400 | `invalid_pinned_user` | non-positive pinned id |
        | 409 | `insufficient_funds` | app balance too low |
        | 409 | `idempotency_conflict` / `idempotency_in_progress` | see Conventions → Idempotency |
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateCheckRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/CreateCheckRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Check" }
        default: { $ref: "#/components/responses/Error" }

  /deleteCheck:
    post:
      tags: [checks]
      operationId: deleteCheck
      summary: Cancel an active check
      description: |
        Scope: `checks`.

        Cancels an outstanding (active) check; the locked amount refunds to
        the app balance. Result: `true`.

        Specific errors: `404 check_not_found` (unknown id or another app's
        check); `409 check_not_active` (already activated or deleted).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [check_id]
              properties:
                check_id: { type: integer, minimum: 1 }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [check_id]
              properties:
                check_id: { type: integer, minimum: 1 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { const: true }
        default: { $ref: "#/components/responses/Error" }

  /getChecks:
    get:
      tags: [checks]
      operationId: getChecks
      summary: List checks
      description: |
        Scope: `read`. Newest first. Deleted checks are never returned.
      parameters:
        - { name: asset, in: query, schema: { type: string }, description: Filter by asset code. }
        - { name: check_ids, in: query, schema: { type: string }, description: Comma-separated check ids. }
        - { name: status, in: query, schema: { type: string, enum: [active, activated] } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: count, in: query, schema: { type: integer, default: 100, minimum: 1, maximum: 1000 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/Check" }
        default: { $ref: "#/components/responses/Error" }

  /transfer:
    post:
      tags: [transfers]
      operationId: transfer
      summary: Pay out to a Telegram user
      description: |
        Scope: `payouts`. Rate limit: 30/min per app.

        Pays a Telegram user out of the app balance; settles atomically at
        creation. The recipient must already be a user of the platform — a
        payout to a mistyped id errors instead of stranding funds.
        `spend_id` is **required** (idempotency; see Conventions).

        Amounts are additionally subject to platform min/max USD bounds
        (best-effort at current prices).

        Specific errors:

        | HTTP | `error.name` | when |
        |---|---|---|
        | 404 | `unknown_asset` | unknown/disabled asset |
        | 400 | `invalid_amount` | unparsable/non-positive amount |
        | 400 | `invalid_recipient` | non-positive `user_id` |
        | 404 | `user_not_found` | recipient has never used the platform |
        | 409 | `recipient_blocked` | recipient's account is blocked |
        | 400 | `amount_too_small` / `amount_too_big` | outside the USD transfer bounds |
        | 409 | `insufficient_funds` | app balance too low |
        | 409 | `idempotency_conflict` / `idempotency_in_progress` | see Conventions → Idempotency |
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransferRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/TransferRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Transfer" }
        default: { $ref: "#/components/responses/Error" }

  /transferBatch:
    post:
      tags: [transfers]
      operationId: transferBatch
      summary: "Batch payouts (extension)"
      description: |
        **Extension over Crypto Bot.** Scope: `payouts`. Rate limit: 10/min
        per app.

        Up to 100 transfers in one call. Items settle **independently, in
        order** — a failed item never rolls back the others. Each item is
        idempotent on its `spend_id` in the same namespace as single
        `transfer`. The call itself returns HTTP 200 with `ok: true` even
        when items failed — check each per-item `ok`.

        Top-level specific error: `400 duplicate_spend_id` (two items share
        a `spend_id` — nothing is executed). Per-item errors use the same
        names as single `transfer`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: "#/components/schemas/TransferRequest" }
      responses:
        "200":
          description: OK — one entry per input item, in order.
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/TransferBatchItemResult" }
        default: { $ref: "#/components/responses/Error" }

  /getTransfers:
    get:
      tags: [transfers]
      operationId: getTransfers
      summary: List transfers
      description: |
        Scope: `read`. Newest first. `spend_id` is an exact-match filter —
        look up a payout by your own idempotency key.
      parameters:
        - { name: asset, in: query, schema: { type: string }, description: Filter by asset code. }
        - { name: transfer_ids, in: query, schema: { type: string }, description: Comma-separated transfer ids. }
        - { name: spend_id, in: query, schema: { type: string } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: count, in: query, schema: { type: integer, default: 100, minimum: 1, maximum: 1000 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/Transfer" }
        default: { $ref: "#/components/responses/Error" }

  /createSubscriptionPlan:
    post:
      tags: [subscriptions]
      operationId: createSubscriptionPlan
      summary: "Create a subscription plan (extension)"
      description: |
        **Extension over Crypto Bot.** Scope: `subscriptions`.

        The plan is an **immutable snapshot** — the mandate a user approves
        is exactly this amount per this period; to change the price, create
        a new plan and archive the old one. Users approve via the plan's
        `mini_app_subscribe_url`; the first period is charged at approval.
        Renewals bill automatically; a failed renewal puts the subscription
        into `grace` with retries inside the platform grace window, then
        `expired`.

        Specific errors: `503 subs_disabled` (feature currently unavailable
        platform-side); `404 unknown_asset`; `400 invalid_amount`;
        `409 invalid_period` (outside the platform period bounds).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePlanRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/CreatePlanRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Plan" }
        default: { $ref: "#/components/responses/Error" }

  /getSubscriptionPlans:
    get:
      tags: [subscriptions]
      operationId: getSubscriptionPlans
      summary: List subscription plans
      description: |
        Scope: `read`. Newest first. Each plan carries
        `active_subscribers` — the count of live (`active` + `grace`)
        subscriptions.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/PlanWithStats" }
        default: { $ref: "#/components/responses/Error" }

  /archiveSubscriptionPlan:
    post:
      tags: [subscriptions]
      operationId: archiveSubscriptionPlan
      summary: Archive a plan
      description: |
        Scope: `subscriptions`.

        Stops **new** approvals; live subscriptions keep renewing on their
        own snapshot (end them per-row via `cancelSubscription`). Archiving
        is irreversible via the API.

        Specific errors: `404 plan_not_found`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan_id]
              properties:
                plan_id: { type: integer, minimum: 1 }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [plan_id]
              properties:
                plan_id: { type: integer, minimum: 1 }
      responses:
        "200":
          description: "OK — the updated plan (`archived: true`)."
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Plan" }
        default: { $ref: "#/components/responses/Error" }

  /getSubscriptions:
    get:
      tags: [subscriptions]
      operationId: getSubscriptions
      summary: List subscriptions
      description: |
        Scope: `read`. Newest first. Merchants read `current_period_end` as
        the access deadline.
      parameters:
        - { name: plan_id, in: query, schema: { type: integer }, description: Filter by plan. }
        - { name: user_id, in: query, schema: { type: integer }, description: Filter by subscriber Telegram id. }
        - { name: status, in: query, schema: { type: string, enum: [active, grace, cancelled, expired] } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: count, in: query, schema: { type: integer, default: 100, minimum: 1, maximum: 500 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result:
                    type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items: { $ref: "#/components/schemas/Subscription" }
        default: { $ref: "#/components/responses/Error" }

  /cancelSubscription:
    post:
      tags: [subscriptions]
      operationId: cancelSubscription
      summary: Cancel a subscription
      description: |
        Scope: `subscriptions`.

        Stops renewals (`cancelled_by: "merchant"`). The already-paid
        period stays usable until `current_period_end`; the status flips to
        `cancelled` immediately. The subscriber is notified; the opt-in
        `subscription_cancelled` webhook fires. A cancelled subscriber may
        re-approve later via the plan link.

        Specific errors: `404 sub_not_found` (unknown id / another app's
        subscription); `409 sub_not_active` (already cancelled or expired).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subscription_id]
              properties:
                subscription_id: { type: integer, minimum: 1 }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [subscription_id]
              properties:
                subscription_id: { type: integer, minimum: 1 }
      responses:
        "200":
          description: OK — the updated subscription.
          content:
            application/json:
              schema:
                type: object
                required: [ok, result]
                properties:
                  ok: { const: true }
                  result: { $ref: "#/components/schemas/Subscription" }
        default: { $ref: "#/components/responses/Error" }

webhooks:
  invoice_paid:
    post:
      summary: An invoice was paid (always delivered)
      description: |
        The only webhook delivered by default (the Crypto Bot contract).
        All other event types are opt-in per app (**Extra webhook events**
        on the Merchant API screen), so a strict Crypto Bot-shaped consumer
        never meets an unknown `update_type` unless it asked for one.

        Every delivery is signed: `TgCryptoPay-API-Signature` (compat alias
        `Crypto-Pay-API-Signature`) = hex HMAC-SHA256 of the raw request
        body, keyed by the raw 32-byte SHA-256 digest of the app's
        **primary** API token. Verify over the raw received bytes.

        A delivery counts as successful on any 2xx response within 10
        seconds; failures are retried with exponential backoff (first gap
        ~10 s, doubling up to 8 h) for up to 17 attempts over roughly
        3 days. `update_id` is stable across retries — dedupe on it. The
        webhook URL is never auto-disabled.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: invoice_paid }
                    payload: { $ref: "#/components/schemas/Invoice" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  invoice_expired:
    post:
      summary: An invoice passed its deadline unpaid (opt-in)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: invoice_expired }
                    payload: { $ref: "#/components/schemas/Invoice" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  check_activated:
    post:
      summary: A check was claimed (opt-in)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: check_activated }
                    payload: { $ref: "#/components/schemas/Check" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  refund_completed:
    post:
      summary: A refund was executed (opt-in)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: refund_completed }
                    payload: { $ref: "#/components/schemas/Invoice" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  subscription_activated:
    post:
      summary: A user approved a plan (opt-in)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: subscription_activated }
                    payload: { $ref: "#/components/schemas/Subscription" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  subscription_charged:
    post:
      summary: A subscription period was billed (opt-in)
      description: |
        `charge.kind` says which period: `initial` (approval charge),
        `renewal`, or `resubscribe` (a cancelled subscriber re-approved).
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: subscription_charged }
                    payload:
                      allOf:
                        - $ref: "#/components/schemas/Subscription"
                        - type: object
                          properties:
                            charge: { $ref: "#/components/schemas/SubscriptionCharge" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  subscription_cancelled:
    post:
      summary: A subscription was cancelled by either side (opt-in)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: subscription_cancelled }
                    payload: { $ref: "#/components/schemas/Subscription" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }
  subscription_expired:
    post:
      summary: The grace window ran out unpaid (opt-in)
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookUpdate"
                - properties:
                    update_type: { const: subscription_expired }
                    payload: { $ref: "#/components/schemas/Subscription" }
      responses:
        "200": { description: Return any 2xx within 10 s to acknowledge. }

components:
  securitySchemes:
    apiToken:
      type: apiKey
      in: header
      name: TgCryptoPay-API-Token
      description: The canonical token header. Shown once at app creation / rotation.
    apiTokenCompat:
      type: apiKey
      in: header
      name: Crypto-Pay-API-Token
      description: Compatibility alias for Crypto Bot client libraries. If both headers are sent, the canonical one wins.

  responses:
    Error:
      description: |
        Error — `{"ok": false, "error": {"code": <HTTP status>, "name":
        "<machine_name>"}}` with the same HTTP status on the wire. See the
        operation description for its specific error names and the API
        description for the common ones.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }

  schemas:
    Amount:
      type: string
      description: Positive decimal string in whole-coin (or fiat) units, e.g. "10.5". Never a JSON number.
      examples: ["10.5"]
    AmountMinor:
      type: string
      description: "Extension: the integer minor-unit amount as a string (wei-scale values overflow the JS Number type)."
      examples: ["10500000"]

    ErrorEnvelope:
      type: object
      required: [ok, error]
      properties:
        ok: { const: false }
        error:
          type: object
          required: [code, name]
          properties:
            code:
              type: integer
              description: Duplicates the HTTP status (400/401/403/404/409/429/503/500).
            name:
              type: string
              description: Machine-readable error name to branch on, e.g. "insufficient_funds".

    AppInfo:
      type: object
      required: [app_id, name, payment_processing_bot_username]
      properties:
        app_id:
          type: string
          description: The app's public id (a string — unlike Crypto Bot's numeric id).
        name: { type: string }
        payment_processing_bot_username:
          type: string
          description: The Telegram bot whose Mini App processes payments.
        webhook_url:
          type: [string, "null"]
        webhook_events:
          type: array
          items: { type: string }
          description: "Extension: extended webhook update types the app opted into (invoice_paid is always delivered and not listed here)."
        scopes:
          type: [array, "null"]
          items: { type: string, enum: [read, invoices, refunds, payouts, checks, subscriptions] }
          description: "Extension: this token's scopes, sorted; null = full-access primary token."
        token_name:
          type: [string, "null"]
          description: "Extension: the restricted token's label; null for the primary token."

    BalanceRow:
      type: object
      required: [currency_code, available, onhold]
      properties:
        currency_code: { type: string, examples: ["USDT"] }
        available: { $ref: "#/components/schemas/Amount" }
        onhold:
          allOf: [{ $ref: "#/components/schemas/Amount" }]
          description: Funds locked in the app's un-activated checks — refundable via deleteCheck, not spendable.
        amount_minor: { $ref: "#/components/schemas/AmountMinor" }

    Currency:
      type: object
      required: [code, name, decimals, is_blockchain, is_stablecoin, is_fiat]
      properties:
        code: { type: string }
        name: { type: string, examples: ["Tether USD"] }
        decimals:
          type: integer
          description: "Minor-unit exponent (crypto: per asset, up to 18; fiat: always 2)."
        is_blockchain: { type: boolean }
        is_stablecoin: { type: boolean }
        is_fiat: { type: boolean }

    ExchangeRate:
      type: object
      required: [is_valid, is_crypto, is_fiat, source, target, rate]
      properties:
        is_valid:
          type: boolean
          description: Whole-table property — false for a degraded/stale rate cache.
        is_crypto: { type: boolean }
        is_fiat: { type: boolean }
        source: { type: string, description: Crypto asset code. }
        target: { type: string, description: Fiat code. }
        rate: { type: string, description: Fiat per 1 whole unit of source, fixed-point string. }

    AppStats:
      type: object
      properties:
        volume:
          type: number
          description: USD value of invoices paid in the window, at current prices (best-effort).
        conversion:
          type: number
          description: Paid/created percentage over the window, 2 decimal places.
        unique_users_count: { type: integer }
        created_invoice_count: { type: integer }
        paid_invoice_count: { type: integer }
        start_at: { type: string, format: date-time }
        end_at: { type: string, format: date-time }

    CreateInvoiceRequest:
      type: object
      properties:
        currency_type:
          type: string
          enum: [crypto, fiat]
          default: crypto
        asset:
          type: string
          description: "Crypto mode: the invoice asset, e.g. USDT. Forbidden together with fiat."
        fiat:
          type: string
          description: "Fiat mode: the pricing fiat (is_fiat rows of getCurrencies)."
        accepted_assets:
          description: "Fiat mode only: assets the payer may pay with — comma-separated string (Crypto Bot form) or JSON array. Omitted = all supported assets."
          oneOf:
            - { type: string, examples: ["USDT,GRAM"] }
            - { type: array, items: { type: string } }
        amount:
          allOf: [{ $ref: "#/components/schemas/Amount" }]
          description: "Required unless open_amount. Crypto mode: in asset units; fiat mode: in fiat units, max 2 decimal places."
        open_amount:
          type: boolean
          default: false
          description: "Extension, crypto mode only: no fixed amount — the payer enters it at pay time. Mutually exclusive with amount."
        description: { type: string, maxLength: 1024, description: Shown to the payer. }
        hidden_message: { type: string, maxLength: 2048, description: Shown to the payer only after payment. }
        payload: { type: string, maxLength: 4096, description: Opaque merchant data echoed back in the invoice object and webhook. }
        allow_comments: { type: boolean, default: true }
        allow_anonymous: { type: boolean, default: true }
        paid_btn_name:
          type: string
          enum: [viewItem, openChannel, openBot, callback]
          description: Post-payment CTA button.
        paid_btn_url:
          type: string
          maxLength: 512
          description: Required when paid_btn_name is set; must start with http:// or https://.
        swap_to:
          type: string
          description: Auto-swap the received amount to this asset after payment, best-effort — payment never fails because of the swap.
        expires_in:
          type: integer
          minimum: 0
          maximum: 2678400
          description: Seconds until expiry (max 31 days); 0 or omitted = never expires.
        rate_lock_seconds:
          type: integer
          minimum: 0
          maximum: 86400
          description: "Extension, fiat mode only: stamp the current rate for every accepted asset and honor it for the window. The server clamps the window to [60 s, the platform maximum]."

    Invoice:
      type: object
      description: The invoice object — API results and invoice webhook payloads.
      properties:
        invoice_id: { type: integer }
        hash: { type: string, description: Public id (also inside pay_url). }
        status: { type: string, enum: [active, paid, expired] }
        currency_type: { type: string, enum: [crypto, fiat] }
        asset:
          type: [string, "null"]
          description: "Crypto invoice: the fixed asset. Fiat invoice: always null here (see paid_asset)."
        fiat: { type: [string, "null"] }
        accepted_assets:
          type: [array, "null"]
          items: { type: string }
          description: "Fiat invoices: the concrete list the payer may pay with; null for crypto invoices."
        amount:
          type: [string, "null"]
          description: "Face amount: fiat units for fiat invoices, crypto units otherwise. Null for an unpaid open-amount invoice."
        amount_minor:
          type: [string, "null"]
          description: "Extension: crypto amount in minor units (string); null while a fiat/open-amount invoice is unpaid."
        paid_asset: { type: [string, "null"], description: "Fiat invoices, once paid: the asset the payer chose." }
        paid_amount: { type: [string, "null"], description: "Fiat invoices, once paid: the crypto amount actually paid." }
        paid_fiat_rate: { type: [string, "null"], description: "Fiat invoices, once paid: the fiat-per-asset rate used." }
        paid_usd_rate: { type: [string, "null"], description: USD price of the paid asset stamped at pay time. }
        fee_asset: { type: [string, "null"] }
        fee_amount:
          type: [string, "null"]
          description: Platform fee, charged to the merchant's leg — the authoritative number for reconciliation.
        fee: { type: [string, "null"], deprecated: true, description: Deprecated alias of fee_amount (Crypto Bot legacy). }
        usd_rate: { type: [string, "null"], deprecated: true, description: Deprecated alias of paid_usd_rate. }
        description: { type: [string, "null"] }
        hidden_message: { type: [string, "null"] }
        payload: { type: [string, "null"] }
        allow_comments: { type: boolean }
        allow_anonymous: { type: boolean }
        comment: { type: [string, "null"], description: The payer's comment (only if allowed). }
        paid_anonymously: { type: boolean }
        is_anonymous: { type: boolean, description: Alias of paid_anonymously. }
        paid_btn_name: { type: [string, "null"] }
        paid_btn_url: { type: [string, "null"] }
        swap_to: { type: [string, "null"] }
        is_swapped: { type: boolean }
        swapped_uid: { type: [string, "null"] }
        swapped_to: { type: [string, "null"] }
        swapped_rate: { type: [string, "null"] }
        swapped_output: { type: [string, "null"] }
        swapped_usd_rate: { type: [string, "null"] }
        swapped_usd_amount: { type: [string, "null"] }
        pay_url: { type: string, description: t.me deep link the payer opens. }
        bot_invoice_url: { type: string, description: Alias of pay_url. }
        mini_app_invoice_url: { type: string, description: Alias of pay_url. }
        web_app_invoice_url: { type: string, description: Alias of pay_url. }
        paid_by_user_id:
          type: [integer, "null"]
          description: The payer's Telegram id — null when the payer paid anonymously.
        open_amount: { type: boolean, description: "Extension: this is an open-amount (donation) invoice." }
        created_at: { type: string, format: date-time }
        paid_at: { type: [string, "null"], format: date-time }
        refunded_amount: { type: [string, "null"], description: "Extension: cumulative refunded amount." }
        refunded_minor: { type: [string, "null"], description: "Extension: the same in minor units (string)." }
        refunded_at: { type: [string, "null"], format: date-time, description: "Extension: stamped once fully refunded (status stays paid)." }
        rate_lock_until: { type: [string, "null"], format: date-time, description: "Extension: rate-lock deadline; null when no lock was requested." }
        rate_lock_rates:
          type: [object, "null"]
          additionalProperties: { type: string }
          description: "Extension: {asset_code: rate_string} stamped at creation — the exact rates quoted while the lock is live."
        expiration_date: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time, description: Alias of expiration_date. }

    RefundInvoiceRequest:
      type: object
      required: [invoice_id]
      properties:
        invoice_id: { type: integer, minimum: 1 }
        amount:
          allOf: [{ $ref: "#/components/schemas/Amount" }]
          description: Refund amount in the invoice's asset. Omitted = the full unrefunded remainder. Partial refunds accumulate up to the face amount.
        spend_id:
          type: string
          minLength: 1
          maxLength: 64
          description: Optional idempotency key — strongly recommended; a timed-out retry replays instead of refunding twice.

    CreateCheckRequest:
      type: object
      required: [asset, amount]
      properties:
        asset: { type: string }
        amount: { $ref: "#/components/schemas/Amount" }
        pin_to_user_id:
          type: integer
          minimum: 1
          description: Restrict claiming to this Telegram user id.
        pin_to_username:
          type: string
          maxLength: 64
          description: Restrict by @username (leading @ optional, case-insensitive); ignored when pin_to_user_id is also given. Must belong to an existing user of the platform.
        spend_id:
          type: string
          minLength: 1
          maxLength: 64
          description: "Extension: optional idempotency key."

    Check:
      type: object
      properties:
        check_id: { type: integer }
        hash: { type: string, description: Public id. }
        asset: { type: string }
        amount: { $ref: "#/components/schemas/Amount" }
        amount_minor: { $ref: "#/components/schemas/AmountMinor" }
        bot_check_url: { type: string, description: t.me claim deep link. }
        status: { type: string, enum: [active, activated] }
        pin_to_user_id: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        activated_at: { type: [string, "null"], format: date-time }

    TransferRequest:
      type: object
      required: [user_id, asset, amount, spend_id]
      properties:
        user_id: { type: integer, minimum: 1, description: Recipient's Telegram user id. }
        asset: { type: string }
        amount:
          allOf: [{ $ref: "#/components/schemas/Amount" }]
          description: Also subject to platform min/max USD bounds.
        spend_id:
          type: string
          minLength: 1
          maxLength: 64
          description: Required idempotency key, unique per app.
        comment: { type: string, maxLength: 1024, description: Shown to the recipient. }
        disable_send_notification:
          type: boolean
          default: false
          description: true = don't send the recipient a Telegram notification.

    Transfer:
      type: object
      properties:
        transfer_id: { type: integer }
        hash: { type: string }
        user_id: { type: integer }
        asset: { type: string }
        amount: { $ref: "#/components/schemas/Amount" }
        amount_minor: { $ref: "#/components/schemas/AmountMinor" }
        spend_id: { type: string }
        comment: { type: [string, "null"] }
        status: { type: string, enum: [completed], description: Transfers settle atomically. }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }

    TransferBatchItemResult:
      type: object
      required: [ok, spend_id]
      properties:
        ok: { type: boolean }
        spend_id: { type: string }
        result:
          allOf: [{ $ref: "#/components/schemas/Transfer" }]
          description: Present when ok is true.
        error:
          type: object
          description: Present when ok is false — same error names as single transfer.
          properties:
            code: { type: integer }
            name: { type: string }

    CreatePlanRequest:
      type: object
      required: [name, asset, amount, period_days]
      properties:
        name: { type: string, minLength: 1, maxLength: 64 }
        asset: { type: string }
        amount:
          allOf: [{ $ref: "#/components/schemas/Amount" }]
          description: The per-period charge.
        period_days:
          type: integer
          minimum: 1
          maximum: 365
          description: Additionally bounded server-side to the platform minimum — outside returns 409 invalid_period.

    Plan:
      type: object
      properties:
        plan_id: { type: integer }
        name: { type: string }
        asset: { type: string }
        amount: { $ref: "#/components/schemas/Amount" }
        amount_minor: { $ref: "#/components/schemas/AmountMinor" }
        period_days: { type: integer }
        archived: { type: boolean, description: Archived plans accept no new approvals. }
        mini_app_subscribe_url: { type: string, description: t.me deep link users open to approve. }
        created_at: { type: string, format: date-time }

    PlanWithStats:
      allOf:
        - $ref: "#/components/schemas/Plan"
        - type: object
          properties:
            active_subscribers:
              type: integer
              description: Count of live (active + grace) subscriptions on the plan.

    Subscription:
      type: object
      properties:
        subscription_id: { type: integer }
        plan_id: { type: integer }
        user_id: { type: integer, description: Subscriber's Telegram id. }
        asset: { type: string, description: Plan snapshot. }
        amount:
          allOf: [{ $ref: "#/components/schemas/Amount" }]
          description: Snapshot per-period amount.
        amount_minor: { $ref: "#/components/schemas/AmountMinor" }
        period_days: { type: integer }
        status:
          type: string
          enum: [active, grace, cancelled, expired]
          description: grace = renewal failed, retrying inside the grace window.
        auto_renew: { type: boolean }
        period_no: { type: integer, description: Number of paid periods so far. }
        current_period_start: { type: string, format: date-time }
        current_period_end:
          type: string
          format: date-time
          description: The paid-through deadline — read this as the access deadline.
        created_at: { type: string, format: date-time }
        cancelled_at: { type: [string, "null"], format: date-time }
        cancelled_by: { type: [string, "null"], enum: [user, merchant, null] }
        expired_at: { type: [string, "null"], format: date-time }

    SubscriptionCharge:
      type: object
      description: The charge sub-object attached to subscription_charged webhook payloads.
      properties:
        period_no: { type: integer }
        kind: { type: string, enum: [initial, renewal, resubscribe] }
        asset: { type: string }
        amount: { $ref: "#/components/schemas/Amount" }
        fee: { $ref: "#/components/schemas/Amount" }
        paid_at: { type: string, format: date-time }

    WebhookUpdate:
      type: object
      required: [update_id, update_type, request_date, payload]
      properties:
        update_id:
          type: integer
          description: Stable across redeliveries of the same event — dedupe on it.
        update_type:
          type: string
          enum: [invoice_paid, invoice_expired, check_activated, refund_completed, subscription_activated, subscription_charged, subscription_cancelled, subscription_expired]
        request_date:
          type: string
          format: date-time
          description: Stamped per delivery attempt.
        payload:
          description: The full object for the event type.
