openapi: 3.1.0

info:
  title: TRON energy rental platform — native API v1
  version: "1.0.0-draft.1"
  summary: Rent TRON energy and bandwidth, activate addresses, automate refills.
  description: |
    Native REST API of the platform. One host per brand (`api.<brand-domain>`), JSON in and out.

    Status: **draft**, 2026-09-11. This file is the contract that `apps/api` implements; it is
    written before the implementation, so treat every `example` as illustrative rather than
    recorded from a live server.

    **Every price in every example is illustrative; live prices come from `GET /v1/prices`.**
    The figures used throughout are CatFee-parity placeholders measured on 2026-09-11 — energy
    `1h` at **20 SUN/unit off-peak** and **30 SUN/unit peak**, activation **1,200,000 SUN**,
    quote TTL **120 s**, and the chain's energy burn price **100 SUN/energy** — and they stand
    in until the full pricing grid is finalised. Never hardcode them.

    ## Conventions

    * **Money is always an integer amount of SUN** (1 TRX = 1 000 000 SUN). There are no floats
      anywhere in this API — not in prices, not in balances, not in quotes. USDT amounts are
      integers in the token's smallest unit (6 decimals), and the field name ends in `_usdt`.
    * **Resource amounts are integers** — energy units, bandwidth units. No fractional units.
    * **Timestamps are RFC 3339 / ISO 8601 strings in UTC** with a `Z` suffix, e.g.
      `2026-09-11T18:04:05.123Z`. Durations are integers in **seconds** unless the field name says
      otherwise.
    * **Tiers** are the product's rental periods, named by slug. The API sells `5m` and `1h`;
      `1d` exists but is switched off and answers `2003 tier_unavailable`. `15m`, `3d` and `30d`
      are retired and never sold. Check `available` on each `GET /v1/prices` row before
      ordering — never hardcode the list.
    * **Pagination** is cursor-based: `limit` + `cursor`, and the response carries `next_cursor`
      (`null` when the page is the last one). Offsets are not supported.
    * **Errors** use real HTTP status codes and a single error envelope (see `Error`). The stable
      numeric `code` and its `slug` are listed in `errors.md`; the HTTP status alone is never
      enough to branch on.
    * **Every response** carries an `X-Request-Id` header. Quote it in support requests.

    ## Authentication

    Three headers on every authenticated request:

    | Header | Value |
    |---|---|
    | `X-API-KEY` | The API key id, as shown in the dashboard. |
    | `X-API-TIMESTAMP` | Current UTC time, ISO 8601 with milliseconds, e.g. `2026-09-11T18:04:05.123Z`. |
    | `X-API-SIGN` | `base64(HMAC_SHA256(api_secret, canonical_string))` |

    The canonical string is the concatenation, with no separators:

    ```
    canonical_string = timestamp + METHOD + path + query + body
    ```

    where

    * `timestamp` is the exact value of the `X-API-TIMESTAMP` header;
    * `METHOD` is the HTTP method upper-cased (`GET`, `POST`, `PATCH`, `DELETE`);
    * `path` is the request path including the `/v1` prefix, e.g. `/v1/orders`, percent-encoded
      exactly as it appears on the wire;
    * `query` is `""` when there is no query string, otherwise `"?"` followed by the raw query
      string **exactly as sent** — parameters are not re-ordered and not re-encoded, so the client
      signs the bytes it is about to transmit;
    * `body` is the raw request body as UTF-8 bytes, `""` for requests without a body.

    The server accepts a clock skew of **±5 seconds**. A request outside that window is rejected
    with `1003 signature_timestamp_skew`, whether it is early or late.

    A key may carry an **IP allowlist**; requests from other addresses fail with
    `1004 ip_not_allowed`. The allowlist is optional, and an empty allowlist means "any IP".

    ### Idempotency

    * Order creation uses **`client_order_id`** in the request body. Re-sending a create with a
      `client_order_id` that already exists for this account returns the **original order** with
      HTTP `200` (instead of `201`) and does not charge again. The body of the repeat request is
      compared with the original; if it differs, the request is rejected with
      `3010 idempotency_conflict` (HTTP `409`) rather than silently applied.
    * Every other mutating endpoint accepts an **`Idempotency-Key`** header with the same
      semantics. It is optional there, and recommended for anything you may retry.
    * Idempotency records are kept for **24 hours**. After that the same key starts a new
      operation.

    ## Accounts, signup and permissions

    There is **one signup model**: the **address challenge**. A customer proves control of a
    TRON address by signing a nonce we issue, off our infrastructure; that address owns the
    account and is the root of its recovery. Email magic-link and Telegram login are
    additional *human* logins attached to the same account — they are not a second way to
    create one. The flow is `POST /v1/accounts/challenge` →
    `POST /v1/accounts/challenge/verify` → `POST /v1/accounts` → `POST /v1/api-keys` → fund
    the deposit address (the key reads it with `GET /v1/deposit-addresses`).

    Two rules follow, and both are deliberate:

    * **An account exists before it can spend.** A created account is `unfunded` until the
      ledger holds a confirmed deposit. It may already issue API keys (since
      2026-09-26), so an integration can read its deposit address and top up automatically;
      orders are refused with `4001 insufficient_funds` until the balance covers them.
    * **Permissions are never defaulted.** `scopes` on an API key is required and has no
      default, and the API invents no starter set (see `createApiKey`). Which capabilities
      each team role carries in the dashboard is configured separately from the API.

    ## Rate limits

    Per API key, and additionally per source IP. Every response carries
    `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). Exceeding a limit
    returns HTTP `429` with `1100 rate_limited` and a `Retry-After` header.

    Default budgets (per key, per second) — the dashboard shows the ones actually in force for
    your key, and wholesale accounts get higher ones on request:

    | Group | Limit |
    |---|---|
    | Order creation (`POST /v1/orders`, `POST /v1/batches`) | 30 rps |
    | Reads (`GET` on orders, quotes, prices, resources) | 50 rps |
    | Account, API keys, webhooks management | 5 rps |

    ## Testnet (Nile)

    Nile exists for the **native API**, on a separate host, with separate keys and a separate
    ledger. Host naming is `api-nile.<brand-domain>` everywhere — in this file, in `llms.txt`,
    in the MCP server and in the dashboard:

    * mainnet: `https://api.<brand-domain>/v1`
    * testnet: `https://api-nile.<brand-domain>/v1`

    **Nile is not an identical testnet, and nothing in our documentation may claim it is.**
    An account, its ledger and its keys belong to exactly one `network`; they do not span
    environments. What differs, and what an integration test must therefore not assume:

    * **Provider execution on Nile is available through ITRX only.** The other providers in the
      cascade (Netts, FeeSaver, TronZap) have no testnet at all, so a fallback path proven on
      Nile has exercised exactly one provider. Testnet supply otherwise comes from a small fixed
      inventory, so a large order may fail with `5001 insufficient_supply` where mainnet would
      succeed.
    * **Lock and unfreeze periods differ.** Nile's maximum delegate lock period is **5 days**
      against mainnet's 30 (`getMaxDelegateLockPeriod` = 144,000 vs 864,000 blocks) and its
      unfreeze delay is **1 day** against mainnet's **14** (`getUnfreezeDelayDays`). The unstake
      pipeline **cannot be exercised on Nile**; it is verified on mainnet only.
    * Testnet balances are credited by a deposit of Nile TRX to the account's Nile deposit
      address; Nile TRX comes from the public faucet at https://nileex.io/join/getJoinPage.
    * `GET /v1/prices` returns `"network": "nile"` so a client can assert which environment it is
      talking to.
    * Webhook deliveries from testnet are signed with the testnet endpoint's own secret. A single
      receiver serving both environments must pick the secret by environment, not by event type.

  contact:
    name: Platform API support
    url: https://example.invalid/support
  license:
    name: Proprietary

servers:
  - url: https://api.{brandDomain}/v1
    description: Mainnet, brand-scoped
    variables:
      brandDomain:
        default: tenergy.me
  - url: https://api-nile.{brandDomain}/v1
    description: Nile testnet, separate keys and ledger
    variables:
      brandDomain:
        default: tenergy.me

security:
  - ApiKeyAuth: []
    ApiTimestamp: []
    ApiSign: []

tags:
  - name: Signup
    description: |
      Creating an account without a credential yet: the address challenge, its verification,
      account creation, and reading the deposit address before the first API key exists.
  - name: Session
    description: |
      The **dashboard** session: the credential a browser holds, and the only one it ever
      holds. An API key is a machine credential and is never used from a page.

      Signing in is one action for the person: the page fetches a challenge
      (`POST /v1/accounts/challenge`), the wallet signs it — free, no transaction, nothing
      leaves the wallet — and the signature comes to `POST /v1/session`, which sets an
      httpOnly cookie. The session survives reloads and navigation, slides forward as it is
      used, and is restored silently with `GET /v1/session`.

      Because the credential is a cookie, every unsafe method additionally needs the
      `X-CSRF-Token` header, whose value is the `csrf_token` of the session response. A
      cross-site page cannot read that response, so it cannot produce the header.
  - name: Account
    description: Who am I, what is my balance, where do I send money.
  - name: Pricing
    description: Price table, quotes and estimates.
  - name: Orders
    description: |
      Buying and inspecting rentals. **There is no single-order cancel in v1**:
      `POST /v1/orders` charges synchronously, so an order is never left sitting unpaid in a
      queue and `created` is barely observable. `3002 order_not_cancellable` belongs to
      `POST /v1/batches/{id}/cancel`, where receivers that have already been picked up cannot
      be pulled back.
  - name: Batches
    description: One request, many receivers.
  - name: Subscriptions
    description: Auto-refill for an address.
  - name: Webhooks
    description: Registering and testing delivery endpoints.
  - name: Chain
    description: Read-only TRON helpers.
  - name: API keys
    description: Managing the credentials of this account.
  - name: Node proxy
    description: Node keys for the TRON node proxy endpoint.

paths:

  # ---------------------------------------------------------------- Signup

  /accounts/challenge:
    post:
      tags: [Signup]
      operationId: createAccountChallenge
      summary: Issue a signing challenge for a TRON address
      description: |
        Step 1 of the **address-challenge** signup and recovery model — the platform's one
        signup model: ownership of an account is proved by signing a nonce with a TRON
        address, off our infrastructure. We never see a private key, and there is nothing
        here to phish — the signature proves control of the address and nothing else.

        The returned `message` is human-readable and domain-bound (brand, purpose, nonce,
        expiry) so that whoever signs it in a wallet can see what they are agreeing to. Sign it
        with a TIP-191-style personal-sign; the signature must recover `address`.

        Anonymous, rate-limited per IP and per address. Calling it for an address that already
        has an account is indistinguishable from calling it for one that does not — this
        endpoint is deliberately not an account-existence oracle.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountChallengeRequest" }
            examples:
              default:
                value:
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  purpose: signup
      responses:
        "201":
          description: Challenge issued.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountChallenge" }
              examples:
                default:
                  value:
                    nonce: "9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e"
                    address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    purpose: signup
                    message: |
                      tenergy.me wants you to prove control of this address.
                      Purpose: signup
                      Address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                      Nonce: 9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e
                      Expires: 2026-09-11T18:14:05.123Z
                      Signing this creates no transaction and moves no funds.
                    issued_at: "2026-09-11T18:04:05.123Z"
                    expires_at: "2026-09-11T18:14:05.123Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /accounts/challenge/verify:
    post:
      tags: [Signup]
      operationId: verifyAccountChallenge
      summary: Verify a signed challenge and get a bootstrap token
      description: |
        Step 2. Checks that `signature` recovers `address` over the challenge `message` and
        returns a short-lived **bootstrap token** — the credential that carries the caller from
        "no account" to "first API key", and the answer to the contract's long-standing gap of
        having no way to authenticate the calls that precede a key.

        The bootstrap token is sent as `Authorization: Bearer abt_…` and opens exactly four
        operations, nothing else: `POST /v1/accounts`, `GET /v1/accounts/deposit-address`,
        `GET /v1/account` and `POST /v1/api-keys`. It expires in 15 minutes, is single-account,
        and is never a substitute for an API key.

        If the address already has an account on this brand, `account_id` and `account_status`
        are returned — to this caller only, because only this caller proved the signature.

        A nonce is single-use. A reused, expired or wrongly-signed nonce is one error,
        `1010 challenge_invalid`, whatever went wrong, so that failures reveal nothing about
        which addresses exist.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountChallengeVerifyRequest" }
            examples:
              default:
                value:
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  nonce: "9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e"
                  signature: "1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b"
      responses:
        "200":
          description: Signature valid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BootstrapToken" }
              examples:
                existingAccount:
                  summary: The address already owns an account
                  value:
                    bootstrap_token: "abt_3f8c2d1e9b7a4c6e8f0a2b4d6e8f0a2b"
                    expires_at: "2026-09-11T18:19:05.123Z"
                    account_id: acc_01J9Z4K2M7Q8
                    account_status: active
                newAddress:
                  summary: No account yet — create one next
                  value:
                    bootstrap_token: "abt_7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d"
                    expires_at: "2026-09-11T18:19:05.123Z"
                    account_id: null
                    account_status: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: |
            `1010 challenge_invalid` — unknown, expired or already-used nonce, or a signature
            that does not recover the address. Take a fresh challenge.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /accounts:
    post:
      tags: [Signup]
      operationId: createAccount
      summary: Create an account
      description: |
        Step 3. Creates the account owned by the signing address and returns it together with
        its deposit address. The account is created in `status: "unfunded"`: it exists, it can
        be read, it can receive money and it can issue API keys; it cannot spend until a
        deposit confirms.

        Authenticate either with the bootstrap token from
        `POST /v1/accounts/challenge/verify`, or by passing `nonce` + `signature` in the body
        for a one-shot create. Both prove the same thing.

        **Idempotent by address.** If the address already owns an account on this brand, the
        existing account is returned with HTTP `200` and nothing is created.

        `email` is optional and is not verified here — it is a recovery and invoicing channel,
        attached later from the dashboard. Email magic-link and Telegram login are additional
        *human* logins on the same account object, not a second signup model.

        **API keys do not wait for a deposit** (since 2026-09-26). The balance, not
        the credential, holds spending back: `POST /v1/orders` answers `4001 insufficient_funds`
        until the ledger has a confirmed credit.
      security:
        - BootstrapToken: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountCreateRequest" }
            examples:
              withBootstrapToken:
                summary: After verifying the challenge
                value:
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
              oneShot:
                summary: Signature in the body, no prior verify call
                value:
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  nonce: "9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e"
                  signature: "1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b"
                  email: "ops@acme.example"
      responses:
        "201":
          description: Account created, unfunded.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountCreated" }
              examples:
                default:
                  value:
                    account_id: acc_01J9Z4K2M7Q8
                    brand: tenergy.me
                    network: mainnet
                    status: unfunded
                    owner_address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    deposit_addresses:
                      - currency: TRX
                        address: TDepositAddressExample1111111111111
                        memo: null
                        confirmations_required: 19
                    next:
                      action: deposit
                      address: TDepositAddressExample1111111111111
                      reason: "Orders are paid from the balance; an API key can be created before the deposit."
                    created_at: "2026-09-11T18:05:00.000Z"
        "200":
          description: The address already owns an account; the existing one is returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: "`1010 challenge_invalid`, or `1011 bootstrap_token_expired`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /accounts/deposit-address:
    get:
      tags: [Signup]
      operationId: getSignupDepositAddress
      summary: Deposit address before the first API key exists
      description: |
        The address to fund so that the account becomes `active` and can issue a key. Readable
        with the bootstrap token, so an agent that has just created an account can report the
        address and the minimum to its user without holding any long-lived credential.

        Once a key exists, use `GET /v1/deposit-addresses`, which returns the same addresses.
      security:
        - BootstrapToken: []
      parameters:
        - name: currency
          in: query
          required: false
          schema: { type: string, enum: [TRX, USDT] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [account_id, status, data]
                properties:
                  account_id: { type: string, examples: ["acc_01J9Z4K2M7Q8"] }
                  status: { $ref: "#/components/schemas/AccountStatus" }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DepositAddress" }
                  min_deposit_sun:
                    allOf: [{ $ref: "#/components/schemas/Sun" }]
                    description: |
                      Below this a transfer is held as an unclaimed residual rather than
                      credited. The value is a brand parameter.
              examples:
                default:
                  value:
                    account_id: acc_01J9Z4K2M7Q8
                    status: unfunded
                    data:
                      - currency: TRX
                        address: TDepositAddressExample1111111111111
                        memo: null
                        confirmations_required: 19
                    min_deposit_sun: 1000000
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Account

  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Account information
      description: |
        Identity, balances, deposit addresses, the limits in force and lifetime counters.
        This is the endpoint a CatFee-compatible facade maps `GET /v1/account` onto.

        Also readable with a bootstrap token (`Authorization: Bearer abt_…`), so the signup
        flow can poll for the first confirmed deposit without an API key.
      security:
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
        - BootstrapToken: []
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
              examples:
                default:
                  value:
                    id: acc_01J9Z4K2M7Q8
                    brand: tenergy.me
                    network: mainnet
                    label: "Acme payments"
                    status: active
                    balance_sun: 1250400000
                    balance_usdt: 0
                    reserved_sun: 0
                    deposit_addresses:
                      - currency: TRX
                        address: TDepositAddressExample1111111111111
                    limits:
                      max_order_energy: 3000000
                      max_batch_receivers: 100
                      orders_per_second: 30
                    totals:
                      deposited_sun: 5000000000
                      spent_sun: 3749600000
                      refunded_sun: 0
                      orders_created: 1842
                      energy_delegated: 119730000
                    created_at: "2026-04-02T09:11:00.000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Account]
      operationId: updateAccount
      summary: Edit the account's display name
      description: |
        The account's own title, shown wherever the account is named. `null` clears it and the
        display falls back to the shortened owner address — never to an internal id.

        A **dashboard session** operation, and the account's **owner**: the title is what every
        member sees and what support reads back in a ticket. It is a setting; nothing about who
        may reach what changes here.
      security:
        - DashboardSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AccountPatch" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Session

  /session:
    post:
      tags: [Session]
      operationId: createSession
      summary: Sign in to the dashboard
      description: |
        Verifies a challenge signed by a TRON address and opens a browser session, returned as
        an httpOnly cookie. If the address owns no account on this brand yet, one is created
        here — `status: unfunded`, the same account `POST /v1/accounts` would have made — so a
        first visit and a return visit are the same single action.

        Signing the challenge is **free and is not a transaction**: no funds move and no key
        leaves the wallet.

        The response body carries `csrf_token`. Send it as `X-CSRF-Token` on every subsequent
        `POST`, `PATCH` or `DELETE` made with this session.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionRequest" }
      responses:
        "201":
          description: Signed in. The session cookie is set.
          headers:
            Set-Cookie:
              description: |
                `__Host-tenergy_session=…; HttpOnly; Secure; SameSite=Lax; Path=/`. Not
                readable by script, which is why the CSRF value is in the body instead.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: "`1010 challenge_invalid` — unknown, expired or reused nonce, or a signature that does not recover the address."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      tags: [Session]
      operationId: getSession
      summary: Restore the session silently
      description: |
        What the dashboard calls on every load. Answers with the live session, or
        `1013 session_expired` when the browser has none — which is not an error condition to
        report to the person, only the signal to show the sign-in screen.
      security:
        - DashboardSession: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401":
          description: "`1013 session_expired` — no session, or it expired or was signed out."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Session]
      operationId: deleteSession
      summary: Sign out
      description: |
        Revokes this session server-side and clears the cookie. Other sessions of the same
        account — another browser, another device — are untouched.
      security:
        - DashboardSession: []
      responses:
        "204": { description: Signed out. No body. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`1014 csrf_token_invalid` — the `X-CSRF-Token` header is missing or wrong."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/InternalError" }

  /session/refresh:
    post:
      tags: [Session]
      operationId: refreshSession
      summary: Extend the session
      description: |
        Slides the expiry forward and reissues the cookie with a fresh lifetime. The session is
        also renewed automatically as it is used, so this is for a page that has been open a
        long time without making a request.
      security:
        - DashboardSession: []
      responses:
        "200":
          description: Extended
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`1014 csrf_token_invalid`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/InternalError" }

  /session/revoke-all:
    post:
      tags: [Session]
      operationId: revokeAllSessions
      summary: Sign out everywhere
      description: |
        Revokes every session **of the signed-in wallet** on this account — every browser and
        device, this one included — and clears the cookie. Sessions of other members are not
        touched. Writes an audit row (`session.revoke_all`). **Dashboard session only**, member
        role `viewer` or above; every API key is refused.
      security:
        - DashboardSession: []
      responses:
        "204": { description: Signed out everywhere. No body. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: "`1014 csrf_token_invalid`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/InternalError" }

  /session/history:
    get:
      tags: [Session]
      operationId: getSessionHistory
      summary: Recent sign-ins
      description: |
        The last ten dashboard sign-ins **of the signed-in wallet** on this account, newest
        first: when, from which IP, with which wallet. A sign-in is recorded when
        `POST /v1/session` issues a session; the history starts on 2026-09-25.

        Sign-ins of other members are not listed: showing one member's IP addresses to another
        is an access decision that has not been taken. **Dashboard session only**, member role
        `viewer` or above.
      security:
        - DashboardSession: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    maxItems: 10
                    items: { $ref: "#/components/schemas/SignIn" }
              examples:
                default:
                  value:
                    data:
                      - signed_in_at: "2026-09-25T09:41:12.000Z"
                        ip: 203.0.113.10
                        address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }

  /balance:
    get:
      tags: [Account]
      operationId: getBalance
      summary: Balances only
      description: |
        A deliberately small response for callers that poll before every order. Cheaper than
        `/account` and on the read rate-limit budget.
      responses:
        "200":
          description: OK
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
              examples:
                default:
                  value:
                    balance_sun: 1250400000
                    balance_usdt: 0
                    reserved_sun: 0
                    available_sun: 1250400000
                    as_of: "2026-09-11T18:04:05.123Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /deposit-addresses:
    get:
      tags: [Account]
      operationId: listDepositAddresses
      summary: Deposit addresses for this account
      description: |
        The account's own deposit address: a TRON address that belongs to this account alone, so
        no memo is needed (`memo` is always `null`). Two rows, same address: `TRX`, and `USDT`
        (TRC-20, `contract`), which the ledger credits as TRX at `rate_now` minus `spread_bps`,
        between `min` and `max` USDT. The address is issued on the first read if the account has none yet.

        A deposit is credited after the configured number of block confirmations
        (`confirmations_required`), and produces a `balance.credited` webhook.

        Support can replace the address (never the customer; at most once per
        `rotation.min_interval_days` and `rotation.max_per_window` times per
        `rotation.window_days`). A replaced address is listed under `retired` and still credits
        until `credited_until` (`rotation.retired_credit_days` after it was retired).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/DepositAddress" }
                  retired:
                    type: array
                    description: Addresses this account used before, newest first.
                    items:
                      type: object
                      required: [address, retired_at, credited_until]
                      properties:
                        address: { $ref: "#/components/schemas/TronAddress" }
                        retired_at: { type: string, format: date-time }
                        credited_until:
                          type: string
                          format: date-time
                          description: A transfer to this address in a block after this instant is not credited automatically; contact support.
                  rotation:
                    type: object
                    description: Who may replace the address, and how often.
                    required: [by, min_interval_days, max_per_window, window_days, retired_credit_days]
                    properties:
                      by: { type: string, enum: [support] }
                      min_interval_days: { type: integer, examples: [7] }
                      max_per_window: { type: integer, examples: [3] }
                      window_days: { type: integer, examples: [90] }
                      retired_credit_days: { type: integer, examples: [30] }
              examples:
                default:
                  value:
                    data:
                      - currency: TRX
                        address: TDepositAddressExample1111111111111
                        memo: null
                        confirmations_required: 19
                    retired:
                      - address: TRetiredAddressExample111111111111
                        retired_at: "2026-09-20T10:00:00.000Z"
                        credited_until: "2026-10-20T10:00:00.000Z"
                    rotation:
                      by: support
                      min_interval_days: 7
                      max_per_window: 3
                      window_days: 90
                      retired_credit_days: 30
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /ledger:
    get:
      tags: [Account]
      operationId: listLedger
      summary: The account's ledger statement
      description: |
        Every journal that moved this account's balance, newest first: deposits, order charges,
        refunds and the rest. `amount_sun` / `amount_usdt` are the net effect on the balance —
        positive when it went up. `kind=deposit` is the deposit history of the top-up screen.

        A deposit appears here once it is **credited**, i.e. after `confirmations_required`
        block confirmations; a transfer still confirming is not listed yet. `sender` and
        `block_number` are `null` on deposits credited before 2026-09-25.

        **Dashboard session only** (member role `viewer` or above). No API-key scope opens it:
        the ledger shows senders and transaction ids that no key-readable endpoint shows today,
        and extending `balance.read` to it is an access decision that has not been taken.
      security:
        - DashboardSession: []
      parameters:
        - name: kind
          in: query
          required: false
          schema: { type: string, enum: [deposit, all], default: all }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LedgerEntry" }
                  next_cursor: { type: [string, "null"] }
              examples:
                default:
                  value:
                    data:
                      - id: jrn_01K5Y8Q2V9M3ZP4T7R1A2B3C4D
                        kind: order_charge
                        amount_sun: -3900000
                        amount_usdt: 0
                        order_id: ord_01K5Y8Q2V9M3ZP4T7R1A2B3C4E
                        deposit: null
                        created_at: "2026-09-25T10:12:04.000Z"
                      - id: jrn_01K5Y7A1B2C3D4E5F6G7H8J9K0
                        kind: deposit
                        amount_sun: 50000000
                        amount_usdt: 0
                        order_id: null
                        deposit:
                          txid: 9f3c1e7a2b4d6f8091a3c5e7f9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b7
                          sender: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                          block_number: 61000123
                          status: credited
                          confirmations_required: 19
                        created_at: "2026-09-25T09:58:40.000Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/addresses:
    get:
      tags: [Account]
      operationId: listAddressBook
      summary: The account's address book
      description: |
        Receivers this account saved for reuse, newest first. At most `limit` (100) entries.

        **Dashboard session only** (member role `viewer` or above). No API-key scope opens it
        yet; opening it to `balance.read` keys is an access decision that has not been taken.
      security:
        - DashboardSession: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, limit, used]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/AddressBookEntry" }
                  limit: { type: integer, examples: [100] }
                  used: { type: integer, examples: [1] }
              examples:
                default:
                  value:
                    data:
                      - id: adr_01K5Y9B7C8D9E0F1G2H3J4K5M6
                        label: Payouts hot wallet
                        address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                        created_at: "2026-09-25T10:20:00.000Z"
                        updated_at: "2026-09-25T10:20:00.000Z"
                    limit: 100
                    used: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Account]
      operationId: saveAddressBookEntry
      summary: Save an address
      description: |
        Adds `address` under `label`. The address is unique per account: saving one that is
        already in the book replaces its label, answers `200` with the existing entry and uses
        no slot. A new address on a full book is `3019 address_book_full`.

        **Dashboard session**, member role `editor` or above, with `X-CSRF-Token`.
      security:
        - DashboardSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AddressBookRequest" }
            examples:
              default:
                value:
                  label: Payouts hot wallet
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
      responses:
        "201":
          description: Saved as a new entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AddressBookEntry" }
        "200":
          description: The address was already saved; its label was replaced.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AddressBookEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: "`3019 address_book_full` — `details.limit`, `details.used`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/addresses/{entryId}:
    parameters:
      - name: entryId
        in: path
        required: true
        schema: { type: string, examples: ["adr_01K5Y9B7C8D9E0F1G2H3J4K5M6"] }
    delete:
      tags: [Account]
      operationId: deleteAddressBookEntry
      summary: Remove an address
      description: |
        Frees the slot immediately. An id of another account answers `404`, like an id that
        does not exist. **Dashboard session**, member role `editor`, with `X-CSRF-Token`.
      security:
        - DashboardSession: []
      responses:
        "204": { description: Removed. No body. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /account/stats:
    get:
      tags: [Account]
      operationId: getAccountStats
      summary: Order analytics, aggregated server-side
      description: |
        What the Analytics screen draws, computed next to the data rather than by summing
        orders in the page. Covers orders **created** in `[from, to)`:

        | field | what is counted |
        |---|---|
        | `orders` | every order, whatever its status |
        | `energy` | delivered energy of energy orders that reached the chain (`delegated` … `reclaimed`): `delivered_amount`, else `amount`; failed and refunded orders add none |
        | `spent_sun` | `total_amount_sun − refunded_amount_sun` of every order, activation included |
        | `avg_price_sun_per_65k` | the unit price those energy orders were sold at, weighted by delivered energy, × 65,000, rounded to a whole SUN; `null` without energy |

        Days and hours are UTC shifted by `utc_offset_minutes`, so the page can ask for its
        visitor's calendar. `series` has one row per day of the range, zeros included.
        `weekday_hour[d][h]` counts orders on ISO weekday `d + 1` (Monday first) at hour `h`.

        **Retention: 13 months.** A `from` older than that is clamped to the retention start;
        `clamped` is `true` and `requested_from` echoes what was asked. Defaults: `to` = now,
        `from` = `to` − 30 days. `to` must be after `from` (`2001` otherwise).

        API key scope **`orders.read`** (the aggregates are sums over the orders that scope
        already lists), or a dashboard session with member role `viewer`.
      security:
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
        - DashboardSession: []
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - name: bucket
          in: query
          schema: { type: string, enum: [day], default: day }
        - name: utc_offset_minutes
          in: query
          schema: { type: integer, minimum: -720, maximum: 840, default: 0 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountStats" }
              examples:
                default:
                  value:
                    from: "2026-09-23T00:00:00.000Z"
                    to: "2026-09-25T00:00:00.000Z"
                    requested_from: "2026-09-23T00:00:00.000Z"
                    clamped: false
                    retention_months: 13
                    bucket: day
                    utc_offset_minutes: 0
                    totals: { orders: 3, energy: 196000, spent_sun: 11760000, avg_price_sun_per_65k: 3900000 }
                    series:
                      - { date: "2026-09-23", orders: 1, energy: 65000, spent_sun: 3900000 }
                      - { date: "2026-09-24", orders: 2, energy: 131000, spent_sun: 7860000 }
                    weekday_hour:
                      - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
                    top_receivers:
                      - { receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE, orders: 2, energy: 131000, spent_sun: 7860000 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Pricing

  /prices:
    get:
      tags: [Pricing]
      operationId: getPrices
      summary: Current price table
      description: |
        Everything currently sellable, with the price for each tier and the volume tiers that
        apply. Prices move with the time of day, so the response carries the window in which the
        quoted numbers hold (`valid_until`), the current pricing period and the whole day's
        `schedule` of periods with the `1h` energy price in each.

        Use this to render a tariff table. To pin a price for an order, take a quote
        (`POST /v1/quotes`) — a price table entry is informational and is **not** binding.

        **Switched-off products.** While the `1d` tier is switched off, the energy `1d` row is
        listed with `available: false`, its `payment_addresses` entry is omitted, and orders,
        quotes and estimates for it answer `2003 tier_unavailable`. While subscriptions are
        switched off, `subscriptions_available` is false and new subscriptions are refused with
        `409` `3021 subscriptions_unavailable`.

        **Public.** No credentials are needed; anonymous calls are limited per source IP. A
        signed request is accepted too and is counted against the key's own budget instead.

        **The example below is illustrative; live prices come from this endpoint.** It carries
        the day-part schedule of 2026-09-25 — energy `1h` from 20 SUN/unit off-peak to 34 SUN/unit
        at peak, here read in the 30 SUN/unit late-peak period — and activation at 1,200,000 SUN; it
        shows one item only. The bandwidth grid, the long-tier prices and the volume steps are
        still being finalised, so the example leaves them out rather than publish a number
        that is not final yet. Iterate over `items`; never assume which tiers or resources
        appear.
      security:
        - {}
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
      parameters:
        - name: resource
          in: query
          required: false
          description: Restrict the response to one resource type.
          schema: { $ref: "#/components/schemas/ResourceType" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceTable" }
              examples:
                default:
                  value:
                    network: mainnet
                    as_of: "2026-09-11T18:04:05.123Z"
                    valid_until: "2026-09-11T18:09:05.123Z"
                    period:
                      id: peak_late
                      label: "Late peak"
                      start: "16:00"
                      end: "00:00"
                    schedule:
                      - { id: drop, label: "Drop", start_utc_minute: 0, end_utc_minute: 60, factor_bps: 13500, price_sun_per_unit: 27 }
                      - { id: off_peak, label: "Off-peak", start_utc_minute: 60, end_utc_minute: 540, factor_bps: 10000, price_sun_per_unit: 20 }
                      - { id: ramp_9, label: "Ramp 09:00", start_utc_minute: 540, end_utc_minute: 660, factor_bps: 11000, price_sun_per_unit: 22 }
                      - { id: ramp_11, label: "Ramp 11:00", start_utc_minute: 660, end_utc_minute: 720, factor_bps: 12000, price_sun_per_unit: 24 }
                      - { id: ramp_12, label: "Ramp 12:00", start_utc_minute: 720, end_utc_minute: 840, factor_bps: 15000, price_sun_per_unit: 30 }
                      - { id: peak, label: "Peak", start_utc_minute: 840, end_utc_minute: 960, factor_bps: 17000, price_sun_per_unit: 34 }
                      - { id: peak_late, label: "Late peak", start_utc_minute: 960, end_utc_minute: 1440, factor_bps: 15000, price_sun_per_unit: 30 }
                    available_energy: 412000000
                    delivered_today: 80600000
                    available_bandwidth: 1800000
                    items:
                      - resource: energy
                        tier: 1h
                        price_sun_per_unit: 30
                        min_amount: 32000
                        max_amount: 3000000
                        volume_tiers: []
                    activation:
                      price_sun: 1200000
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /market:
    get:
      tags: [Pricing]
      operationId: getMarket
      summary: Public energy rental market board
      description: |
        The latest collected 1h / 1d energy price per public provider (worker collector, every
        5 min), plus our own row priced by the same service as `GET /v1/prices`. Sorted by
        `price_sun_1h` ascending, unpriced providers last; our row (`slug: tenergy`) is placed by
        its real price and on a tie sorts behind the competitor. `savings_pct` = 1 − price /
        `burn_sun`; `stale` = the price row is older than 15 min (or missing). Anonymous; a
        presented key only buys the per-key budget.
      security:
        - {}
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
      responses:
        "200":
          description: The board.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MarketBoard" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /market/history:
    get:
      tags: [Pricing]
      operationId: getMarketHistory
      summary: One provider's collected price history
      description: Ok collector points for one provider, oldest first. Unknown slug = empty series.
      security:
        - {}
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
      parameters:
        - { name: slug, in: query, required: true, schema: { type: string, maxLength: 64 } }
        - { name: hours, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 720, default: 24 } }
      responses:
        "200":
          description: The series.
          content:
            application/json:
              schema:
                type: object
                required: [slug, hours, points]
                properties:
                  slug: { type: string }
                  hours: { type: integer }
                  points:
                    type: array
                    items:
                      type: object
                      required: [ts, price_sun_1h, price_sun_1d, available_energy]
                      properties:
                        ts: { type: string, format: date-time }
                        price_sun_1h: { type: [number, "null"] }
                        price_sun_1d: { type: [number, "null"] }
                        available_energy: { type: [integer, "null"] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
  /orderbook:
    get:
      tags: [Pricing]
      operationId: getOrderBook
      summary: The ask ladder for one resource and tier
      description: |
        Guaranteed delivery at a price that rises with size. The platform publishes a ladder of
        **levels** - `amount @ price`, cheapest first - and an order simply walks it: the first
        units come from the cheapest level, the next from the one above, and so on. A bigger or
        a later order pays more per unit, and it is always fillable. There is no "sold out".

        Every level carries a `class`, which says how the energy reaches you and nothing about
        who supplies it:

        | class | what it is |
        |---|---|
        | `instant` | the platform's own capacity, delivered immediately |
        | `market`  | bought for you on the wholesale market |
        | `deep`    | the on-chain market, always available, dearest |

        Prices are monotonic - a level never undercuts the one before it - and no level is ever
        below the published floor. Pass `amount` to get the walk for that size back with the
        levels: `fills`, the volume-weighted `unit_price_sun` and the `total_sun`.

        Anonymous and cached for a few seconds. This is a price **display**: to pin a price,
        take a quote (`POST /v1/quotes`), which also holds the own-capacity part of the ladder
        for its lifetime so the same units are not sold twice.
      security: []
      parameters:
        - name: resource
          in: query
          required: false
          schema: { type: string, enum: [energy, bandwidth], default: energy }
        - name: tier
          in: query
          required: false
          schema: { $ref: "#/components/schemas/Tier" }
        - name: amount
          in: query
          required: false
          description: Walk the ladder for this many units and return the fills as well.
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderBook" }
              examples:
                default:
                  value:
                    resource: energy
                    tier: 1h
                    as_of: "2026-09-19T18:04:05.123Z"
                    valid_until: "2026-09-19T18:04:08.123Z"
                    floor_sun: 20
                    depth: 14200000
                    levels:
                      - { price_sun: 30, amount: 1200000, class: instant }
                      - { price_sun: 34, amount: 3000000, class: market }
                      - { price_sun: 58, amount: 10000000, class: deep }
                    walk: null
                    cheaper_from: "2026-09-20T01:00:00.000Z"
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /estimate:
    get:
      tags: [Pricing]
      operationId: estimateOrder
      summary: Stateless price estimate
      description: |
        What an order would cost right now, without creating anything and without reserving a
        price. Cheap, safe to call on every keystroke.

        The example is illustrative — 65,000 energy at the 20 SUN/unit off-peak placeholder —
        and live prices come from `GET /v1/prices`.

        The estimate is **not binding**: between the estimate and the order the pricing period may
        roll over. If you need the number you showed the user to be the number you are charged,
        take a quote instead and pass `quote_id` to `POST /v1/orders`, or send `max_price_sun`.

        **Public.** Anonymous calls get the retail price and are limited per source IP. A signed
        request is priced for its own account, so a contract customer sees the contract price.
      security:
        - {}
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
      parameters:
        - name: resource
          in: query
          required: true
          schema: { $ref: "#/components/schemas/ResourceType" }
        - name: amount
          in: query
          required: true
          description: Energy or bandwidth units.
          schema: { type: integer, format: int64, minimum: 1, examples: [65000] }
        - name: tier
          in: query
          required: true
          schema: { $ref: "#/components/schemas/Tier" }
        - name: receiver
          in: query
          required: false
          description: |
            When given, the estimate includes the activation fee if the address is not yet
            activated on chain. Without it, `activate_amount_sun` is `0` and the total may be
            understated for a fresh address.
          schema: { $ref: "#/components/schemas/TronAddress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
              examples:
                default:
                  value:
                    resource: energy
                    amount: 65000
                    tier: 1h
                    receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    price_sun_per_unit: 20
                    energy_amount_sun: 1300000
                    activate_amount_sun: 0
                    total_amount_sun: 1300000
                    receiver_activated: true
                    as_of: "2026-09-11T18:04:05.123Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /quotes:
    post:
      tags: [Pricing]
      operationId: createQuote
      summary: Create a binding quote
      description: |
        Pins a price for a short window. Pass the returned `id` as `quote_id` when creating the
        order and you are charged exactly `total_amount_sun`, even if the pricing period rolled
        over in between.

        A quote reserves a price, **not** inventory. If supply runs out before the order is
        created, the order fails with `5001 insufficient_supply` and nothing is charged.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteRequest" }
            examples:
              default:
                value:
                  resource: energy
                  amount: 65000
                  tier: 1h
                  receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
      responses:
        "201":
          description: Quote created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
              examples:
                default:
                  value:
                    id: qt_01J9Z5NB2K4R
                    resource: energy
                    amount: 65000
                    tier: 1h
                    receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    price_sun_per_unit: 20
                    energy_amount_sun: 1300000
                    activate_amount_sun: 0
                    total_amount_sun: 1300000
                    created_at: "2026-09-11T18:04:05.123Z"
                    expires_at: "2026-09-11T18:06:05.123Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /quotes/{quoteId}:
    parameters:
      - name: quoteId
        in: path
        required: true
        schema: { type: string, examples: ["qt_01J9Z5NB2K4R"] }
    get:
      tags: [Pricing]
      operationId: getQuote
      summary: Read a quote
      description: Returns the quote, including whether it is still usable (`expires_at` in the future).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Quote" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Orders

  /orders:
    post:
      tags: [Orders]
      operationId: createOrder
      summary: Create an order
      description: |
        Buys a rental for one receiver and charges the account balance. A tier whose
        `GET /v1/prices` row says `available: false` (energy `1d` while switched off) is refused
        with `2003 tier_unavailable` before anything is charged.

        **The response is not a delivery receipt.** A `201` means the order was accepted, paid for
        and handed to the supply layer; `status` tells you how far it got by the time the response
        was written. In the common case delivery is synchronous and you get back a delivered
        order with `delegate_hash` populated within a few seconds — `status: "confirmed"` if
        the response is written in the same instant the delivery is verified, `active`
        thereafter, which is the value you will normally see. **Treat `confirmed` and `active`
        alike: both mean the resource is on the receiver.** When the supply layer needs longer,
        you get `status: "allocating"` and no hash yet — poll `GET /v1/orders/{id}` or, better,
        subscribe to the `order.confirmed` webhook.

        **Check `partial`.** An order whose allocations only partly landed comes back
        `confirmed`/`active` with `partial: true`, `delivered_amount` below `amount` and the
        difference already refunded. It is not a separate state and it is not a failure.

        **Idempotency.** Always send `client_order_id`. A repeat with the same id returns the
        original order with HTTP `200` and charges nothing. This is the correct response to a
        network timeout: retry verbatim rather than creating a second order.

        **Price protection.** Either pass `quote_id` (charged exactly the quoted total) or
        `max_price_sun` (rejected with `3006 price_above_limit` if the live price is higher). With
        neither, you are charged the live price whatever it is.

        **Activation.** If `receiver` is not activated on chain, the platform activates it and adds
        `activate_amount_sun` to the charge. Set `activate: false` to refuse that: the order is
        then rejected with `3004 receiver_not_activated` and nothing is charged.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderRequest" }
            examples:
              energy1h:
                summary: 65k energy for one hour
                value:
                  client_order_id: "acme-2026-09-11-000418"
                  resource: energy
                  amount: 65000
                  tier: 1h
                  receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  activate: true
              withQuote:
                summary: Against a pinned quote
                value:
                  client_order_id: "acme-2026-09-11-000419"
                  quote_id: qt_01J9Z5NB2K4R
              bandwidth:
                summary: Bandwidth, price-capped
                value:
                  client_order_id: "acme-2026-09-11-000420"
                  resource: bandwidth
                  amount: 1000
                  tier: 1h
                  receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  max_price_sun: 500000
              activation:
                summary: Address activation only
                value:
                  client_order_id: "acme-2026-09-11-000421"
                  resource: activation
                  receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
      responses:
        "201":
          description: Order created and charged.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
              examples:
                delivered:
                  summary: Delivered synchronously
                  value:
                    id: ord_01J9Z5P8T3WQ
                    client_order_id: "acme-2026-09-11-000418"
                    account_id: acc_01J9Z4K2M7Q8
                    resource: energy
                    amount: 65000
                    delivered_amount: 65000
                    partial: false
                    tier: 1h
                    duration_seconds: 3600
                    receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    source: api
                    status: active
                    confirm_status: confirmed
                    price_sun_per_unit: 20
                    pay_amount_sun: 1300000
                    activate_amount_sun: 0
                    total_amount_sun: 1300000
                    refunded_amount_sun: 0
                    delegate_hash: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
                    delegate_hashes:
                      - "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
                    delegated_at: "2026-09-11T18:04:07.900Z"
                    reclaim_hash: null
                    reclaimed_at: null
                    expires_at: "2026-09-11T19:04:07.900Z"
                    activation:
                      performed: false
                      hash: null
                      amount_sun: 0
                    created_at: "2026-09-11T18:04:05.400Z"
                    updated_at: "2026-09-11T18:04:07.900Z"
                    failure: null
                pending:
                  summary: Accepted, delivery still running
                  value:
                    id: ord_01J9Z5P8T3WR
                    client_order_id: "acme-2026-09-11-000422"
                    account_id: acc_01J9Z4K2M7Q8
                    resource: energy
                    amount: 2500000
                    delivered_amount: null
                    partial: false
                    tier: 1h
                    duration_seconds: 3600
                    receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    source: api
                    status: allocating
                    confirm_status: unconfirmed
                    price_sun_per_unit: 20
                    pay_amount_sun: 50000000
                    activate_amount_sun: 0
                    total_amount_sun: 50000000
                    refunded_amount_sun: 0
                    delegate_hash: null
                    delegate_hashes: []
                    delegated_at: null
                    reclaim_hash: null
                    reclaimed_at: null
                    expires_at: null
                    activation: { performed: false, hash: null, amount_sun: 0 }
                    created_at: "2026-09-11T18:04:05.400Z"
                    updated_at: "2026-09-11T18:04:05.400Z"
                    failure: null
        "200":
          description: |
            The `client_order_id` already exists for this account and the request body matches the
            original. The existing order is returned; nothing was created and nothing was charged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    get:
      tags: [Orders]
      operationId: listOrders
      summary: List orders
      description: |
        Newest first. Filters combine with AND.

        `format=csv` answers `text/csv` with **every** matching order rather than one page
        (`limit` and `cursor` are ignored), streamed, with the same fields as the JSON list:
        one column per `Order` field in contract order, `activation.*` and `failure.*`
        flattened, `delegate_hashes` space-separated. A text cell that begins with `=`, `+`,
        `-`, `@`, a tab or a carriage return is prefixed with `'` so a spreadsheet does not
        run it as a formula.
      parameters:
        - name: status
          in: query
          description: Repeat the parameter to match several states.
          schema:
            type: array
            items: { $ref: "#/components/schemas/OrderStatus" }
          style: form
          explode: true
        - name: resource
          in: query
          schema: { $ref: "#/components/schemas/ResourceType" }
        - name: receiver
          in: query
          schema: { $ref: "#/components/schemas/TronAddress" }
        - name: client_order_id
          in: query
          description: Exact match. The fastest way to find an order after a lost response.
          schema: { type: string }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
        - name: from
          in: query
          description: Inclusive lower bound on `created_at` — the dashboard's date range.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Exclusive upper bound on `created_at`.
          schema: { type: string, format: date-time }
        - name: format
          in: query
          schema: { type: string, enum: [json, csv], default: json }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  next_cursor:
                    type: [string, "null"]
                    description: Pass back as `cursor` for the next page; `null` on the last page.
                    examples: ["eyJpZCI6Im9yZF8wMUo5WjVQOFQzV1EifQ"]
            text/csv:
              schema:
                type: string
                description: "`format=csv`: a header row, then one row per order, CRLF line ends."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /orders/{orderId}:
    parameters:
      - name: orderId
        in: path
        required: true
        description: |
          Either the platform order id (`ord_…`) or, prefixed with `cid:`, your own
          `client_order_id` — e.g. `cid:acme-2026-09-11-000418`. The second form saves you from
          storing our id at all.
        schema: { type: string, examples: ["ord_01J9Z5P8T3WQ"] }
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Order detail
      description: |
        The order plus two breakdowns only this endpoint carries. `fills` is the priced walk:
        how much came from each price class and at what unit price. `delegations` is what reached
        the receiver on chain: one row per delegation with its tx id. No supplier is named.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Order"
                  - type: object
                    required: [fills, delegations]
                    properties:
                      fills:
                        type: array
                        items:
                          type: object
                          required: [class, amount, price_sun]
                          properties:
                            class: { type: string, enum: [instant, market, deep] }
                            amount: { type: integer, description: Units priced in this class. }
                            price_sun: { type: [number, "null"], description: "SUN per unit; null when unreadable." }
                      delegations:
                        type: array
                        items:
                          type: object
                          required: [amount, tx_id]
                          properties:
                            amount: { type: integer, description: Units delivered (requested until confirmed). }
                            tx_id: { type: string, description: Delegation transaction id. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /orders/{orderId}/reclaim:
    parameters:
      - name: orderId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Orders]
      operationId: reclaimOrder
      summary: Return the resource before expiry
      description: |
        Undelegates the resource early. Useful once the transaction you rented for has landed: the
        energy stops sitting idle and the inventory can serve someone else.

        **No refund.** Renting and returning are separate operations; returning early does not undo
        the payment. Reclaim exists so that high-volume users free inventory, not to buy time back.

        **Idempotent.** Calling it twice returns the same `reclaim_hash` and changes nothing. An
        order that already expired on its own also answers `200`.

        **Per order, not per address.** One address may hold resource from several orders, possibly
        belonging to other accounts. To free an address completely, reclaim each of its orders.

        Orders filled from a third-party provider instead of our own stake cannot be reclaimed —
        those resources are not ours to return. Such an order answers `3008 reclaim_unavailable`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Reclaimed, or already reclaimed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
              examples:
                default:
                  value:
                    id: ord_01J9Z5P8T3WQ
                    client_order_id: "acme-2026-09-11-000418"
                    account_id: acc_01J9Z4K2M7Q8
                    resource: energy
                    amount: 65000
                    delivered_amount: 65000
                    partial: false
                    tier: 1h
                    duration_seconds: 3600
                    receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    source: api
                    status: reclaimed
                    confirm_status: confirmed
                    price_sun_per_unit: 20
                    pay_amount_sun: 1300000
                    activate_amount_sun: 0
                    total_amount_sun: 1300000
                    refunded_amount_sun: 0
                    delegate_hash: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
                    delegate_hashes: ["a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"]
                    delegated_at: "2026-09-11T18:04:07.900Z"
                    reclaim_hash: "51fa77da06e8fbebf504fbf088d1d9611059398d51fa77da06e8fbebf504fbf0"
                    reclaimed_at: "2026-09-11T18:12:31.000Z"
                    expires_at: "2026-09-11T19:04:07.900Z"
                    activation: { performed: false, hash: null, amount_sun: 0 }
                    created_at: "2026-09-11T18:04:05.400Z"
                    updated_at: "2026-09-11T18:12:31.000Z"
                    failure: null
        "202":
          description: |
            Reclaim accepted but the on-chain transaction had not appeared before the response was
            written. It will almost certainly land on its own — repeat the call in a few seconds to
            collect the hash, or wait for the `order.reclaimed` webhook.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            Nothing to reclaim (`3007`) or the order was filled by a third-party provider
            (`3008`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Batches

  /batches:
    post:
      tags: [Batches]
      operationId: createBatch
      summary: Order for many receivers in one call
      description: |
        Up to 100 receivers per request. For each receiver the platform runs the whole sequence —
        activate the address if it is not active, top up its bandwidth if it is short, then deliver
        the resource, splitting large amounts into chunks automatically.

        The response is **`202 Accepted`**: the batch is queued, nothing is charged yet, and
        nothing has been delivered. Read the result from `GET /v1/batches/{id}` or from the
        per-order webhooks.

        One receiver failing never affects the others, and a failed activation or bandwidth step
        does not stop the resource order for that receiver.

        Receivers are billed individually, at the price in force when each one is executed — so a
        long batch may span a pricing period boundary. Pass `max_price_sun` per item to cap that.

        Each receiver produces its own order with its own id, which is what you reclaim, inspect
        and reconcile against. `client_batch_id` is the idempotency key for the batch as a whole:
        a repeat with the same id and the same body returns the original batch; the same id with a
        different body is rejected with `3010 idempotency_conflict`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BatchRequest" }
            examples:
              default:
                value:
                  client_batch_id: "acme-payout-2026-09-11-01"
                  defaults:
                    resource: energy
                    tier: 1h
                    amount: 65000
                    activate: true
                    bandwidth: true
                    bandwidth_amount: 400
                  items:
                    - receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    - receiver: TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
                      amount: 131000
                    - receiver: TYYyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
                      bandwidth: false
      responses:
        "202":
          description: Batch accepted and queued. Nothing charged yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Batch" }
              examples:
                default:
                  value:
                    id: bat_01J9Z6Q1V8XZ
                    client_batch_id: "acme-payout-2026-09-11-01"
                    status: queued
                    items_accepted: 3
                    summary:
                      total: 3
                      queued: 3
                      processing: 0
                      completed: 0
                      partial: 0
                      failed: 0
                      insufficient_funds: 0
                      cancelled: 0
                    items:
                      - receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                        tracking_id: "acme-payout-2026-09-11-01:TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"
                        status: queued
                        resource: energy
                        amount: 65000
                        tier: 1h
                        order_ids: []
                        delegate_hashes: []
                        activation: { status: planned, hash: null }
                        bandwidth: { status: planned, order_id: null, skip_reason: null }
                        failure: null
                    created_at: "2026-09-11T18:20:00.000Z"
                    finished_at: null
        "200":
          description: Same `client_batch_id` and same body — the original batch is returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Batch" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

    get:
      tags: [Batches]
      operationId: listBatches
      summary: List batches
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Batch" }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /batches/{batchId}:
    parameters:
      - name: batchId
        in: path
        required: true
        description: Platform id (`bat_…`) or `cid:<client_batch_id>`.
        schema: { type: string }
    get:
      tags: [Batches]
      operationId: getBatch
      summary: Batch progress
      parameters:
        - name: receiver
          in: query
          description: Return only the item for this address instead of the whole batch.
          schema: { $ref: "#/components/schemas/TronAddress" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Batch" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /batches/{batchId}/cancel:
    parameters:
      - name: batchId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Batches]
      operationId: cancelBatch
      summary: Cancel the not-yet-started part of a batch
      description: |
        Removes from the queue every receiver that has not been picked up yet. Receivers already
        being processed are **not** interrupted — part of their resource may already be paid for.
        Cancel is best-effort on the remainder.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [id, cancelled]
                properties:
                  id: { type: string, examples: ["bat_01J9Z6Q1V8XZ"] }
                  cancelled:
                    type: integer
                    description: How many receivers were removed from the queue.
                    examples: [7]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Subscriptions

  /subscriptions/plans:
    get:
      operationId: listSubscriptionPlans
      tags: [Subscriptions]
      summary: Subscription presets, limits and the fee rule
      description: |
        Public; an API key is verified when presented. A preset only fills `reserve`, `low` and
        `high`; any rule inside `limits` is accepted (`PlanSubscriptionRequest`). Fee per day =
        ceil(reserve × `fee_rule.trx_per_unit` / `fee_rule.unit_energy`) whole TRX. Refills in every
        plan are priced at the 1 h grid of the current day-part (`per_use`). `average_price` is
        computed server-side for 10/50/200/1,000 uses a day.
      security: []
      responses:
        "200":
          description: Active plans, smallest reserve first.
          content:
            application/json:
              schema:
                type: object
                required: [available, data, per_use, limits, fee_rule, at]
                properties:
                  available:
                    type: boolean
                    description: False while subscriptions are switched off on this deployment; the plans are shown, not sold.
                  data: { type: array, items: { $ref: "#/components/schemas/SubscriptionPlan" } }
                  per_use:
                    type: object
                    required: [mode, energy_per_use, price_sun, price_trx, day_part]
                    properties:
                      mode: { type: string, enum: [grid], description: "The 1 h energy price of the current day-part × 65,000." }
                      energy_per_use: { type: integer }
                      price_sun: { type: [integer, "null"], description: null while 1 h energy is paused. }
                      price_trx: { type: [number, "null"] }
                      day_part: { type: [string, "null"], description: Day-part window id. }
                  limits:
                    type: object
                    required: [reserve_min, reserve_max, step, low_min, gap_min]
                    description: "131,000 ≤ reserve ≤ 5,240,000; each number a multiple of `step`; low ≥ `low_min`; low < high ≤ reserve; high − low ≥ `gap_min`. Exception: low = high = reserve = 131,000 (Basic)."
                    properties:
                      reserve_min: { type: integer }
                      reserve_max: { type: integer }
                      step: { type: integer }
                      low_min: { type: integer }
                      gap_min: { type: integer }
                  fee_rule:
                    type: object
                    required: [trx_per_unit, unit_energy, rounding]
                    properties:
                      trx_per_unit: { type: integer }
                      unit_energy: { type: integer }
                      rounding: { type: string, enum: [ceil_whole_trx] }
                  at: { type: string, format: date-time }

  /subscriptions:
    post:
      tags: [Subscriptions]
      operationId: createSubscription
      summary: Auto-refill an address
      description: |
        Keeps an address supplied with energy. A subscription is three numbers
        (`PlanSubscriptionRequest`): `{address, preset}` or `{address, reserve, low, high}`.

        * `reserve` (R) — the energy held for the address; 131,000 ≤ R ≤ 5,240,000, step 1,000.
        * `low` — refill when the address's free energy drops below it; `low` ≥ 65,000.
        * `high` — refill up to it; `high − low` ≥ 65,000 and `high` ≤ R. The `basic` preset
          (`low` = `high` = R) is the one exception.

        | Preset | Reserve | Daily fee |
        |---|---|---|
        | `basic` | 131,000 | 6 TRX |
        | `1.3m` | 1,300,000 | 60 TRX |
        | `2.62m` | 2,620,000 | 120 TRX |
        | `5.24m` | 5,240,000 | 240 TRX |

        Billing: the daily fee is `ceil(R × 6 / 131,000)` whole TRX. The first day is charged when
        the subscription starts, then every 24 hours. Each refill is charged at the live `1h`
        price. A rule outside the limits of `GET /v1/subscriptions/plans` is `422`
        `3020 subscription_rule_invalid` with `details.violations`. `PATCH` takes
        `{reserve, low, high}`, `{preset}` or `{status}` (`PlanSubscriptionPatch`).

        While `GET /v1/subscriptions/plans` → `available` is false this call is `409`
        `3021 subscriptions_unavailable`.

        One address may have at most one subscription per resource. A second one is rejected with
        `3011 subscription_exists`.

        Legacy body: `mode: "refill"` / `mode: "renewal"` with `threshold_amount` is still
        accepted for existing integrations; new integrations use the three numbers above. The
        `201` example shows a legacy subscription; its numbers are illustrative.

        Scope `subscriptions.write`, or a dashboard session with member role `editor` and
        `X-CSRF-Token`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionRequest" }
            examples:
              plan_preset:
                summary: Plan subscription from a preset
                value:
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  preset: basic
              plan_custom:
                summary: Plan subscription with your own three numbers
                value:
                  address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  reserve: 1300000
                  low: 325000
                  high: 975000
              refill:
                summary: Legacy body
                value:
                  receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  resource: energy
                  mode: refill
                  tier: 1h
                  threshold_amount: 65000
                  refill_amount: 131000
                  max_price_sun: 3000000
                  max_refills_per_day: 48
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
              examples:
                default:
                  value:
                    id: sub_01J9Z7R4Y2AB
                    receiver: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    resource: energy
                    mode: refill
                    tier: 1h
                    threshold_amount: 65000
                    refill_amount: 131000
                    max_price_sun: 3000000
                    max_refills_per_day: 48
                    daily_fee_sun: 3930000
                    status: active
                    last_refill_at: null
                    last_order_id: null
                    refills_today: 0
                    created_at: "2026-09-11T18:30:00.000Z"
                    updated_at: "2026-09-11T18:30:00.000Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

    get:
      tags: [Subscriptions]
      operationId: listSubscriptions
      summary: List subscriptions
      description: |
        Scope `subscriptions.read`, or a dashboard session with member role `viewer`.
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/SubscriptionStatus" }
        - name: receiver
          in: query
          schema: { $ref: "#/components/schemas/TronAddress" }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Subscription" }
                  next_cursor: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /subscriptions/{subscriptionId}:
    parameters:
      - name: subscriptionId
        in: path
        required: true
        schema: { type: string, examples: ["sub_01J9Z7R4Y2AB"] }
    get:
      tags: [Subscriptions]
      operationId: getSubscription
      summary: Read a subscription
      description: |
        Scope `subscriptions.read`, or a dashboard session with member role `viewer`. Another
        account's subscription answers `404`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Subscriptions]
      operationId: updateSubscription
      summary: Change or pause a subscription
      description: |
        Send any subset of the mutable fields. `status` accepts `active` and `paused` only —
        `suspended` is set by the platform when funds run out and clears itself, and `cancelled`
        is reached through `DELETE`. An empty body is rejected with `2002 empty_patch`.

        While subscriptions are switched off, a patch that changes the rule, preset or refill
        parameters is `409` `3021 subscriptions_unavailable`; `status`, `label`, reads and
        `DELETE` keep working so existing subscriptions can be managed.

        Scope `subscriptions.write`, or a dashboard session with member role `editor` and
        `X-CSRF-Token`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SubscriptionPatch" }
            examples:
              pause:
                value: { status: paused }
              retune:
                value: { threshold_amount: 100000, refill_amount: 200000 }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Subscription" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Subscriptions]
      operationId: cancelSubscription
      summary: Cancel a subscription
      description: |
        Stops future refills. Rentals already delivered keep running until they expire; they are
        not reclaimed and not refunded. The subscription stays readable with `status: "cancelled"`.
        Scope `subscriptions.write`, or a dashboard session with member role `editor` and
        `X-CSRF-Token`.
      responses:
        "204":
          description: Cancelled. No body.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Webhooks

  /webhooks:
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a delivery endpoint
      description: |
        Returns a `secret` **shown exactly once**. It signs every delivery to this endpoint — store
        it now; if you lose it, rotate rather than re-register.

        At most two endpoints per account: one `primary` and one `backup`. Delivery is not fan-out
        — every event goes to the primary, and moves to the backup only after the primary's retries
        are exhausted. The first endpoint you create becomes primary.

        URL requirements, enforced on create and on every edit: `https` only; must resolve to a
        public address (loopback, RFC 1918, link-local including `169.254.169.254` and other
        non-routable ranges are rejected); no credentials in the URL; at most 2048 characters.

        Full event catalogue, payloads, signature scheme and retry schedule: `webhooks.md`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookRequest" }
            examples:
              default:
                value:
                  url: "https://acme.example/hooks/tenergy"
                  role: primary
                  events: ["order.confirmed", "order.failed", "balance.credited"]
      responses:
        "201":
          description: Created. `secret` is present only in this response.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: Signing secret. Shown once, never returned by GET.
                        examples: ["whsec_9f2a7c1e4b6d8a0f3e5c7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a"]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List delivery endpoints
      description: The `secret` is never returned here.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, max_endpoints]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
                  max_endpoints: { type: integer, examples: [2] }
                  roles:
                    type: array
                    items: { type: string, enum: [primary, backup] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhookId}:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { type: string, examples: ["wh_01J9Z8S6Z3CD"] }
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Read one endpoint
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Edit an endpoint
      description: |
        Change `url`, `events`, `is_active` and/or `role`. Sending `{"role": "primary"}` to the
        backup **swaps** the two roles in one transaction, so you are never left without a primary.
        A changed URL is re-validated. `is_active: false` pauses delivery without losing the
        endpoint; pausing the primary does **not** promote the backup — swap the roles if that is
        what you want.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookPatch" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete an endpoint
      description: Hard delete; frees the role. Undelivered events for this endpoint are dropped.
      responses:
        "204": { description: Deleted. No body. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhookId}/rotate-secret:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Issue a new signing secret
      description: |
        Returns a new secret **once**. It takes effect immediately for subsequent deliveries; there
        is no overlap window, so deploy the new secret to your receiver before rotating, or accept
        a brief period where in-flight retries fail signature verification. Each endpoint has its
        own secret — rotating the primary's does not touch the backup's.
      responses:
        "200":
          description: Rotated
          content:
            application/json:
              schema:
                type: object
                required: [id, secret]
                properties:
                  id: { type: string }
                  secret: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhookId}/test:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Send a test delivery
      description: |
        Sends a synthetic event to the endpoint and reports what your server answered. The payload
        is signed exactly like a real one and carries `"test": true` at the top level, so a
        receiver that ignores unknown event types, or that checks the flag, will not act on it.

        Test deliveries are **not retried** and do not appear in delivery history.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  $ref: "#/components/schemas/WebhookEventType"
      responses:
        "200":
          description: The delivery was attempted; the result describes what happened.
          content:
            application/json:
              schema:
                type: object
                required: [delivered, event, delivery_id]
                properties:
                  delivered:
                    type: boolean
                    description: True when your endpoint answered 2xx.
                  event: { $ref: "#/components/schemas/WebhookEventType" }
                  delivery_id: { type: string, examples: ["dlv_test_01J9Z9T8"] }
                  response_status:
                    type: [integer, "null"]
                    description: HTTP status your endpoint returned; `null` on connection failure.
                    examples: [200]
                  response_body_excerpt:
                    type: [string, "null"]
                    description: First 512 bytes of your response, for debugging.
                  error:
                    type: [string, "null"]
                    description: Transport-level failure description, when `delivered` is false.
                  duration_ms: { type: integer, examples: [143] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhookId}/deliveries:
    parameters:
      - name: webhookId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Delivery log of one endpoint
      description: |
        Every event queued for this endpoint, newest first: which event, how many attempts, the
        state, the last HTTP status your server answered and when the next retry is due. Test
        deliveries are not listed. Scope `webhooks.read`, or a dashboard session with member
        role `viewer`. Another account's endpoint answers `404`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  next_cursor: { type: [string, "null"] }
              examples:
                default:
                  value:
                    data:
                      - id: dlv_01K5YA1B2C3D4E5F6G7H8J9K0M
                        event_id: evt_01K5YA1B2C3D4E5F6G7H8J9K0N
                        event: order.confirmed
                        attempt: 2
                        state: failed
                        response_status: 502
                        error: null
                        next_attempt_at: "2026-09-25T10:31:00.000Z"
                        delivered_at: null
                        created_at: "2026-09-25T10:30:00.000Z"
                        updated_at: "2026-09-25T10:30:30.000Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------------- Chain helpers

  /resources/{address}:
    parameters:
      - name: address
        in: path
        required: true
        schema: { $ref: "#/components/schemas/TronAddress" }
    get:
      tags: [Chain]
      operationId: getAddressResources
      summary: Resource situation of an address
      description: |
        What the chain currently says about an address: whether it is activated, how much free
        energy and bandwidth it has, how much is delegated to it and by whom, and which of those
        delegations came from us.

        Read from our own node with a short cache (`as_of` tells you how fresh it is). Use it to
        decide whether an order is needed at all — the cheapest energy is the energy you do not buy.

        **Public.** No credentials are needed; anonymous calls are limited per source IP and get
        `our_active_orders: []`. A signed request lists that account's own active orders on the
        address and is counted against the key's budget.
      security:
        - {}
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AddressResources" }
              examples:
                default:
                  value:
                    address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    activated: true
                    balance_sun: 4120000
                    holds_usdt: true
                    energy:
                      limit: 131000
                      used: 0
                      available: 131000
                      delegated_in: 131000
                    bandwidth:
                      limit: 1600
                      used: 0
                      available: 1600
                      delegated_in: 0
                      free_net_limit: 600
                    our_active_orders:
                      - order_id: ord_01J9Z5P8T3WQ
                        amount: 65000
                        resource: energy
                        expires_at: "2026-09-11T19:04:07.900Z"
                    as_of: "2026-09-11T18:40:00.000Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /status:
    get:
      tags: [Chain]
      operationId: getStatus
      summary: Service status
      description: |
        Whether TEnergy is working right now, component by component: the database, incoming
        payments (how long ago the payment scanner last advanced), energy delivery over the last
        hour (orders, failures, median seconds from order to delegation) and energy available to
        sell. `status` is the worst component. Delivery is judged only on an hour with at least 3
        orders; a quiet hour is not an incident.

        **Public.** No credentials are needed. The answer is cached for 10 seconds. It is `503` when
        a component is `down`, so a plain HTTP check alerts without reading the body.
      security:
        - {}
      responses:
        "200":
          description: Operational or degraded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceStatus" }
              examples:
                default:
                  value:
                    status: operational
                    network: mainnet
                    as_of: "2026-09-29T10:00:00.000Z"
                    components:
                      api: { status: operational }
                      database: { status: operational }
                      payments: { status: operational, scanner_updated_s_ago: 3 }
                      delivery: { status: operational, orders_1h: 42, failed_1h: 0, median_delivery_s_1h: 6 }
                      supply: { status: operational, available_energy: 18500000 }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: A component is down; the body has the same shape.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ServiceStatus" }

  /estimate/transfer:
    post:
      tags: [Chain]
      operationId: estimateTransferEnergy
      summary: Energy needed for a TRC-20 transfer
      description: |
        How much energy a TRC-20 transfer from `from_address` to `to_address` will consume, and
        what renting that much would cost. Defaults to USDT (`TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`)
        when `contract_address` is omitted.

        The number depends on whether the recipient already holds a non-zero balance of that
        token — a first-time recipient costs roughly twice as much — which is why both addresses
        are required. The estimate is produced by a `triggerConstantContract` simulation against
        our node, so it reflects the current contract state, not a table.

        Add a safety margin before ordering: the simulation is taken now and the transfer is
        broadcast later, and the recipient's balance may change in between. `recommended_amount`
        already includes the platform's margin and is the number to order.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TransferEstimateRequest" }
            examples:
              usdt:
                value:
                  from_address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                  to_address: TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TransferEstimate" }
              examples:
                default:
                  value:
                    from_address: TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE
                    to_address: TXXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
                    contract_address: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
                    recipient_holds_token: false
                    energy_required: 130285
                    recommended_amount: 131000
                    bandwidth_required: 345
                    tier: 1h
                    price_sun_per_unit: 20
                    energy_amount_sun: 2620000
                    activate_amount_sun: 1200000
                    total_amount_sun: 3820000
                    burn_alternative_sun: 13028500
                    as_of: "2026-09-11T18:41:00.000Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  # ---------------------------------------------------------------- API keys

  /node-keys:
    get:
      tags: [Node proxy]
      operationId: listNodeKeys
      summary: List node keys (keys themselves are never returned)
      description: Same guard as `GET /api-keys` (scope `keys.read`). `used` counts active keys; `limit` is `NODE_KEYS_PER_ACCOUNT` (default 5).
      responses:
        "200":
          description: The account's node keys.
          content:
            application/json:
              schema:
                type: object
                required: [data, limit, used]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/NodeKey" }
                  limit: { type: integer }
                  used: { type: integer }
    post:
      tags: [Node proxy]
      operationId: createNodeKey
      summary: Create a node key; the key and endpoint are shown once
      description: |
        Same guard as `POST /api-keys` (scope `keys.create`, member editor); no new scope. A node
        key lets the node proxy buy 5 m energy orders for transactions broadcast through it,
        charged to this account's own balance, and nothing else. Only its SHA-256 is stored.
        `3017 api_key_limit_reached` when the account already holds `NODE_KEYS_PER_ACCOUNT` active keys.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                label: { type: string, minLength: 1, maxLength: 64 }
      responses:
        "201":
          description: Created. `key` and `endpoint` appear in this response only.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/NodeKey"
                  - type: object
                    required: [key, endpoint]
                    properties:
                      key: { type: string, description: "26 chars base32, 128 bits." }
                      endpoint: { type: string, description: "`<PROXY_PUBLIC_HOST>/<key>`." }
        "409":
          description: "`3017 api_key_limit_reached`."
  /node-keys/{id}:
    delete:
      tags: [Node proxy]
      operationId: revokeNodeKey
      summary: Revoke a node key (active = false)
      description: Same guard as `DELETE /api-keys/{keyId}` (scope `keys.revoke`). The proxy stops accepting the key within its cache TTL.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Revoked. }
        "404": { description: No such node key on this account. }
  /api-keys:
    get:
      tags: [API keys]
      operationId: listApiKeys
      summary: List the keys of this account
      description: |
        Secrets are never returned by this endpoint — a secret exists in a response exactly once,
        at creation. The `key` shown here is the public identifier sent in `X-API-KEY`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, limit, used]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
                  limit:
                    type: integer
                    description: |
                      How many **active** keys this account may hold at once. A product limit,
                      not a permission: it caps how many credentials exist, never what any of
                      them may do. Past it, `POST /api-keys` answers
                      `3017 api_key_limit_reached`; revoking a key frees a slot immediately.
                    examples: [10]
                  used:
                    type: integer
                    description: Active keys right now — revoked and expired ones do not count.
                    examples: [3]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [API keys]
      operationId: createApiKey
      summary: Create an API key
      description: |
        Creates a key and returns its **secret once**. The secret is not stored in a recoverable
        form; if it is lost, delete the key and create another.

        The first key of an account is created with the **bootstrap token** from the signup
        flow; every later one with an existing key that itself holds `keys.create`.

        **Also on an unfunded account** (since 2026-09-26), so an integration can read
        its deposit address with the new key and top up without a human in the loop.

        `scopes` is **required and has no default**. The API deliberately does not invent a
        starter set of permissions: a key's reach is a decision for the account owner, taken
        with the list in front of them. A request without `scopes` is rejected with
        `2001 validation_failed`, and no client library, agent flow or dashboard form may
        supply one on the owner's behalf.

        ### One permission vocabulary

        Permissions are named `area.action` and there is **one list of names** for the whole
        platform. The names below are the ones an **API key** may carry in v1. The remaining
        names in that vocabulary — `balance.withdraw`, `billing.write`, `invoices.read`,
        `referral.read`, `referral.withdraw`, `team.read`, `team.invite`, `team.remove`,
        `team.grant`, `settings.write`, `audit.read` — exist for use elsewhere in the platform
        (team management in the dashboard) but are **not key-eligible in v1**.

        | Scope | What it opens |
        |---|---|
        | `prices.read` | `POST /estimate/transfer` (`GET /prices`, `GET /estimate` and `GET /resources/{address}` are public and need no scope) |
        | `balance.read` | `GET /account`, `GET /balance` — balances and lifetime counters |
        | `balance.topup_address` | `GET /deposit-addresses` — the account's deposit address and its retired ones |
        | `orders.read` | `GET /orders` (also as CSV), `GET /orders/{id}`, `GET /batches…`, `GET /quotes/{id}`, `GET /account/stats` (sums over the same orders) |
        | `orders.create` | `POST /orders`, `POST /quotes`, `POST /batches`, `POST /batches/{id}/cancel` — **spends money** |
        | `orders.reclaim` | `POST /orders/{id}/reclaim` — ends a rental early, no refund |
        | `subscriptions.read` | `GET /subscriptions…` |
        | `subscriptions.write` | create, patch, cancel subscriptions — **commits to recurring spend** |
        | `webhooks.read` | `GET /webhooks…`, including `GET /webhooks/{id}/deliveries` |
        | `webhooks.write` | create, patch, delete, rotate, test webhook endpoints |
        | `keys.read` | `GET /api-keys` |
        | `keys.create` | `POST /api-keys`, `PATCH /api-keys/{id}` — **a key with this can widen its own account's reach** |
        | `keys.revoke` | `DELETE /api-keys/{id}` — can stop a production integration instantly |

        `GET /ledger`, `GET /account/addresses` and `GET /session/history` are dashboard-session
        operations and no scope opens them to a key.

        A key can only be created with scopes that the creating key itself holds, so
        `keys.create` cannot be used to escalate beyond what the caller already has. A key
        created with the bootstrap token may carry any key-eligible scope, because the signing
        address is the account's owner.
      security:
        - ApiKeyAuth: []
          ApiTimestamp: []
          ApiSign: []
        - BootstrapToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyRequest" }
            examples:
              readonlyMonitor:
                summary: A monitoring key that cannot spend — scopes chosen explicitly, not a template
                value:
                  label: "grafana exporter"
                  scopes: ["balance.read", "orders.read"]
                  ip_allowlist: ["203.0.113.10"]
              orderingService:
                summary: A key for the service that actually buys
                value:
                  label: "payouts worker"
                  scopes: ["prices.read", "orders.read", "orders.create"]
                  ip_allowlist: ["203.0.113.20", "203.0.113.21"]
      responses:
        "201":
          description: Created. `secret` appears only here.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiKey"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: |
                          HMAC secret, 256 bits, **shown once**. `sk_live_…` on the production
                          host and `sk_test_…` on the Nile host, matching the key id. It is
                          not stored in a recoverable form: if it is lost, revoke the key and
                          create another.
                        examples: ["sk_live_7d1f9b3c5e7a9c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e"]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            `3017 api_key_limit_reached` — the account already holds the maximum number of
            active keys. `details.limit` and `details.used`; revoke one to free a slot.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                limitReached:
                  value:
                    error:
                      code: 3017
                      slug: api_key_limit_reached
                      message: "This account already has 10 of 10 active keys"
                      retryable: false
                      details:
                        limit: 10
                        used: 10
                    request_id: req_01J9Z5P8T3WQ7X
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api-keys/{keyId}:
    parameters:
      - name: keyId
        in: path
        required: true
        schema: { type: string, examples: ["key_01J9ZA1D9EFG"] }
    patch:
      tags: [API keys]
      operationId: updateApiKey
      summary: Edit a key
      description: |
        Change `label`, `ip_allowlist`, `is_active`, or `scopes`. Narrowing `scopes` takes effect
        immediately. Widening them is subject to the same rule as creation: the calling key must
        already hold every scope being granted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ApiKeyPatch" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [API keys]
      operationId: deleteApiKey
      summary: Revoke a key
      description: |
        Immediate and irreversible. In-flight requests signed with this key start failing at once;
        orders it already created are unaffected and keep running.

        A key cannot delete itself — that would leave an account with no way back in if it were the
        last one. Revoking your own key is done from the dashboard.
      responses:
        "204": { description: Revoked. No body. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

# ==================================================================== components

components:

  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-KEY
      description: The API key id.
    ApiTimestamp:
      type: apiKey
      in: header
      name: X-API-TIMESTAMP
      description: Request time, ISO 8601 UTC with milliseconds. ±5 s tolerance.
    ApiSign:
      type: apiKey
      in: header
      name: X-API-SIGN
      description: |
        `base64(HMAC_SHA256(secret, timestamp + METHOD + path + query + body))`. See the
        Authentication section of the API description for the exact canonical string.
    DashboardSession:
      type: apiKey
      in: cookie
      name: __Host-tenergy_session
      description: |
        The dashboard's browser session, set by `POST /v1/session`. **httpOnly**, so no script
        reads it; `SameSite=Lax`; thirty days, sliding. Send the request with credentials
        included.

        Because the cookie travels on its own, every unsafe method additionally needs
        `X-CSRF-Token`, whose value is the `csrf_token` of the session response. A cross-site
        page cannot read that response, so it cannot forge the header.

        This credential never carries API-key scopes: what a signed-in person may do is their
        member role on the account.
    BootstrapToken:
      type: http
      scheme: bearer
      bearerFormat: abt
      description: |
        Short-lived signup credential from `POST /v1/accounts/challenge/verify`, sent as
        `Authorization: Bearer abt_…`. Valid for 15 minutes, bound to one address and one
        account, and accepted by exactly four operations: `createAccount`,
        `getSignupDepositAddress`, `getAccount` and `createApiKey`. It is not an API key and
        cannot order, spend or read anything else.

  headers:
    XRequestId:
      description: Correlation id for this request. Include it in support tickets.
      schema: { type: string, examples: ["req_01J9Z5P8T3WQ7X"] }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, examples: [1] }

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-chosen key making this mutating request safe to retry, 8–128 characters of
        `A-Z a-z 0-9 . _ : -`. A repeat with the same key and the same body returns the original
        result; the same key with a different body is rejected with `3010 idempotency_conflict`.
        Records live for 24 hours.

        For order creation prefer `client_order_id` in the body — it is the same mechanism and it
        is also what you look the order up by later.
      schema: { type: string, minLength: 8, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" }
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor from a previous response's `next_cursor`.
      schema: { type: string }

  responses:
    BadRequest:
      description: Malformed request — bad JSON, unknown field, wrong type.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: 2001
                  slug: validation_failed
                  message: "amount must be an integer >= 32000"
                  field: amount
                  retryable: false
                request_id: req_01J9Z5P8T3WQ7X
    Unauthorized:
      description: Missing, malformed or rejected credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            badSignature:
              value:
                error:
                  code: 1002
                  slug: invalid_signature
                  message: "Signature does not match the canonical string"
                  retryable: false
                request_id: req_01J9Z5P8T3WQ7X
    Forbidden:
      description: Authenticated, but this key is not allowed to do it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            scope:
              value:
                error:
                  code: 1005
                  slug: insufficient_scope
                  message: "This key lacks the orders.create scope"
                  retryable: false
                request_id: req_01J9Z5P8T3WQ7X
    PaymentRequired:
      description: Not enough balance to cover the order.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: 4001
                  slug: insufficient_funds
                  message: "Required 1300000 SUN, available 420000 SUN"
                  retryable: true
                  details:
                    required_sun: 1300000
                    available_sun: 420000
                request_id: req_01J9Z5P8T3WQ7X
    NotFound:
      description: No such object, or it belongs to another account. The two are not distinguished.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: The request contradicts the current state of the object.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableEntity:
      description: Syntactically valid but semantically impossible.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            default:
              value:
                error:
                  code: 1100
                  slug: rate_limited
                  message: "Rate limit exceeded for this key"
                  retryable: true
                request_id: req_01J9Z5P8T3WQ7X
    InternalError:
      description: Something broke on our side.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServiceUnavailable:
      description: |
        Temporarily unable to serve. On order creation this is fail-secure: nothing was stored and
        nothing was charged, so retrying verbatim with the same `client_order_id` is safe.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:

    # ---- primitives

    TronAddress:
      type: string
      description: Base58Check TRON address (starts with `T`, 34 characters).
      pattern: "^T[1-9A-HJ-NP-Za-km-z]{33}$"
      examples: ["TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE"]

    TxHash:
      type: string
      description: TRON transaction id, 64 lowercase hex characters.
      pattern: "^[0-9a-f]{64}$"

    Sun:
      type: integer
      format: int64
      minimum: 0
      description: An amount in SUN (1 TRX = 1 000 000 SUN). Always an integer.

    ResourceType:
      type: string
      enum: [energy, bandwidth, activation]
      description: |
        * `energy` — TRON energy, the resource a TRC-20 transfer consumes.
        * `bandwidth` — TRON bandwidth (net), consumed by transaction size.
        * `activation` — one-off account activation; `amount` and `tier` do not apply.

    Tier:
      type: string
      enum: ["5m", "15m", "1h", "1d", "3d", "30d"]
      description: |
        Rental period. The API sells `5m` and `1h`; `1d` exists but is switched off. `15m`, `3d`
        and `30d` are retired and never sold — they stay in the enum only so old orders parse.
        Check `available` on each `GET /v1/prices` row; a tier that is not available is rejected
        with `2003 tier_unavailable`.

    OrderStatus:
      type: string
      enum: [created, paid, allocating, delegated, confirmed, active, expired, reclaimed, failed, refunded]
      description: |
        The one order state machine, spelled identically in this contract, `webhooks.md` and
        the dashboard:

        ```
        created → paid → allocating → delegated → confirmed → active → expired | reclaimed
                                                   terminal branches: failed | refunded
        ```

        | Value | Meaning |
        |---|---|
        | `created` | Row exists, not yet charged. Rarely observed via the API. |
        | `paid` | Charged against the ledger; supply not yet chosen. |
        | `allocating` | The supply layer is choosing sources and building the delegation. |
        | `delegated` | Delegation transaction broadcast; not yet seen in a block. |
        | `confirmed` | Delivery verified on chain (see `webhooks.md`). The rental window starts at the confirmed block time, so this state is **transient**: it is the instant the `order.confirmed` event fires, and the order moves to `active` in the same breath. |
        | `active` | The persistent state for the rest of the rental window: the resource is on the receiver and `expires_at` is in the future. An order with several allocations is `active` while at least one is confirmed and none has expired. |
        | `expired` | The rental window ended; the resource was returned automatically. |
        | `reclaimed` | Returned early on your request. Terminal, no refund. |
        | `failed` | Could not be delivered. Terminal. Any charge is reversed — see `refunded_amount_sun`. |
        | `refunded` | Delivered but later reversed and credited back. Terminal. |

        Terminal states: `expired`, `reclaimed`, `failed`, `refunded`. Everything else can still
        change.

        **There are no other order states.** Partial delivery is not a state — it is the
        `partial` flag plus `delivered_amount` on the order, and such an order is `confirmed`
        then `active` like any other. Anything that looks like a sub-state is a field.

    ConfirmStatus:
      type: string
      enum: [unconfirmed, confirmed, confirm_failed]
      description: |
        On-chain confirmation of the delegation, independent of the order's business state.
        `unconfirmed` means the transaction has not been seen in a block yet — it is not a failure.

        This duplicates what `status` already encodes (`delegated` = broadcast, `confirmed` =
        in a block and verified). It exists **for CatFee compatibility**, because their facade
        needs a separate confirmation field to map onto; new integrations should branch on
        `status`. Documented here so the pair does not drift apart.

    SourceType:
      type: string
      enum: [api, dashboard, transfer, bot, subscription, batch, proxy]
      description: Where the order came from. `transfer` is a direct-transfer purchase with no account.

    SubscriptionStatus:
      type: string
      enum: [active, paused, suspended, cancelled]
      description: |
        `paused` is set by you; `suspended` is set by the platform when the balance cannot cover the
        next refill and clears itself after a deposit; `cancelled` is terminal.

    WebhookEventType:
      type: string
      enum:
        - order.confirmed
        - order.failed
        - order.expired
        - order.reclaimed
        - order.refunded
        - batch.completed
        - subscription.refilled
        - subscription.suspended
        - subscription.paused
        - subscription.charged
        - balance.credited
        - balance.low
        - deposit_address.rotated
      description: |
        Full catalogue with payloads in `webhooks.md`. `balance.credited` for a deposit also
        carries `asset` (`TRX`|`USDT`), `usdt_amount`, `rate` (SUN per USDT after spread),
        `rate_source`, `spread_bps`, `txid`, `sender`, `block_number`; the USDT fields are `null`
        for TRX.

    # ---- error

    StatusLevel:
      type: string
      enum: [operational, degraded, down]
    ServiceStatus:
      type: object
      required: [status, network, as_of, components]
      properties:
        status: { $ref: "#/components/schemas/StatusLevel" }
        network: { type: string, enum: [mainnet, nile] }
        as_of: { type: string, format: date-time }
        components:
          type: object
          required: [api, database, payments, delivery, supply]
          properties:
            api:
              type: object
              required: [status]
              properties: { status: { $ref: "#/components/schemas/StatusLevel" } }
            database:
              type: object
              required: [status]
              properties: { status: { $ref: "#/components/schemas/StatusLevel" } }
            payments:
              type: object
              required: [status, scanner_updated_s_ago]
              properties:
                status: { $ref: "#/components/schemas/StatusLevel" }
                scanner_updated_s_ago:
                  type: [integer, "null"]
                  description: Seconds since the payment scanner last advanced; null before its first block.
            delivery:
              type: object
              required: [status, orders_1h, failed_1h, median_delivery_s_1h]
              properties:
                status: { $ref: "#/components/schemas/StatusLevel" }
                orders_1h: { type: integer }
                failed_1h: { type: integer }
                median_delivery_s_1h: { type: [integer, "null"] }
            supply:
              type: object
              required: [status, available_energy]
              properties:
                status: { $ref: "#/components/schemas/StatusLevel" }
                available_energy: { type: integer, description: Energy that can be sold right now for 1 hour. }
    Error:
      type: object
      required: [error, request_id]
      properties:
        error:
          type: object
          required: [code, slug, message, retryable]
          properties:
            code:
              type: integer
              description: Stable numeric code. Never reused, never renumbered. See `errors.md`.
              examples: [4001]
            slug:
              type: string
              description: Stable machine-readable name for the same condition. Branch on this.
              examples: ["insufficient_funds"]
            message:
              type: string
              description: Human-readable English explanation. Wording may change; do not parse it.
            field:
              type: [string, "null"]
              description: The offending request field, for validation errors.
            retryable:
              type: boolean
              description: |
                Whether retrying the identical request could succeed later. `false` means fix
                something first. See `errors.md` for per-code retry guidance.
            details:
              type: [object, "null"]
              description: Extra machine-readable context; shape depends on the code.
              additionalProperties: true
        request_id:
          type: string
          description: Also returned as the `X-Request-Id` header.

    # ---- signup

    AccountStatus:
      type: string
      enum: [unfunded, active, suspended, closed]
      description: |
        | Value | Meaning |
        |---|---|
        | `unfunded` | Created, readable, can receive a deposit and issue API keys. Cannot order until a deposit confirms (`4001 insufficient_funds`). |
        | `active` | A deposit has been confirmed; the account can order. |
        | `suspended` | Can read but not order. |
        | `closed` | Terminal. |

    AccountChallengeRequest:
      type: object
      required: [address]
      properties:
        address: { $ref: "#/components/schemas/TronAddress" }
        purpose:
          type: string
          enum: [signup, login, recovery]
          default: signup
          description: What the signature will be used for; it appears in the signed message.

    AccountChallenge:
      type: object
      required: [nonce, address, message, issued_at, expires_at]
      properties:
        nonce: { type: string, description: Single-use, 32 hex characters. }
        address: { $ref: "#/components/schemas/TronAddress" }
        purpose: { type: string, enum: [signup, login, recovery] }
        message:
          type: string
          description: |
            The exact string to sign, TIP-191 personal-sign style. Domain-bound and
            human-readable, so that a person signing it in a wallet can see what it says.
            Sign these bytes verbatim — do not re-wrap, trim or re-encode them.
        issued_at: { type: string, format: date-time }
        expires_at:
          type: string
          format: date-time
          description: Ten minutes after issue. An expired nonce gives `1010 challenge_invalid`.

    AccountChallengeVerifyRequest:
      type: object
      required: [address, nonce, signature]
      properties:
        address: { $ref: "#/components/schemas/TronAddress" }
        nonce: { type: string }
        signature:
          type: string
          description: Hex signature over `message`. Must recover `address`; `0x` prefix optional.

    BootstrapToken:
      type: object
      required: [bootstrap_token, expires_at]
      properties:
        bootstrap_token:
          type: string
          description: |
            Send as `Authorization: Bearer abt_…`. Fifteen-minute lifetime, four permitted
            operations, no ordering and no spending. Store it no longer than the signup flow.
        expires_at: { type: string, format: date-time }
        account_id:
          type: [string, "null"]
          description: The account this address already owns, or `null` when there is none yet.
        account_status:
          oneOf:
            - $ref: "#/components/schemas/AccountStatus"
            - type: "null"

    AccountCreateRequest:
      type: object
      required: [address]
      description: |
        Send `nonce` + `signature` for a one-shot create, or omit both and authenticate with
        the bootstrap token from `POST /v1/accounts/challenge/verify`.
      properties:
        address: { $ref: "#/components/schemas/TronAddress" }
        nonce: { type: string }
        signature: { type: string }
        email:
          type: [string, "null"]
          format: email
          description: |
            Optional recovery and invoicing contact. Not verified here and not required; an
            account with no recovery channel is a support incident waiting to happen, so the
            dashboard nudges for one later.
        label: { type: [string, "null"], maxLength: 128 }

    AccountCreated:
      type: object
      required: [account_id, brand, network, status, owner_address, deposit_addresses, created_at]
      properties:
        account_id: { type: string }
        brand: { type: string }
        network: { type: string, enum: [mainnet, nile] }
        status: { $ref: "#/components/schemas/AccountStatus" }
        owner_address:
          allOf: [{ $ref: "#/components/schemas/TronAddress" }]
          description: The address whose signature owns this account and can recover it.
        deposit_addresses:
          type: array
          items: { $ref: "#/components/schemas/DepositAddress" }
        next:
          type: object
          description: |
            What the caller should do next, machine-readable, because an agent that cannot
            infer the next step will invent one.
          properties:
            action: { type: string, examples: ["deposit"] }
            address: { $ref: "#/components/schemas/TronAddress" }
            reason: { type: string }
        created_at: { type: string, format: date-time }

    # ---- account

    Account:
      type: object
      required: [id, brand, network, status, balance_sun, deposit_addresses, created_at]
      properties:
        id: { type: string, examples: ["acc_01J9Z4K2M7Q8"] }
        brand:
          type: string
          description: The brand this account belongs to. Accounts are not shared across brands.
          examples: ["tenergy.me"]
        network:
          type: string
          enum: [mainnet, nile]
          description: |
            The environment this account belongs to. An account, its ledger and its keys do
            not span environments: a Nile account is a separate account on the
            `api-nile.<brand-domain>` host.
        owner_address:
          oneOf: [{ $ref: "#/components/schemas/TronAddress" }, { type: "null" }]
          description: |
            The address that signed the challenge this account was created with; the root of
            recovery. `null` only for legacy accounts created before the challenge model.
        label:
          type: [string, "null"]
          description: |
            The same value as `display_name`, under the name this field has always had. New
            clients should read `display_name`; this one is not going away.
        display_name:
          type: [string, "null"]
          description: |
            The account's own title, chosen by its owner and editable with
            `PATCH /v1/account`. `null` when it has none, in which case a UI shows the
            shortened `owner_address` instead of an internal id.
          examples: ["Payouts, production"]
        status: { $ref: "#/components/schemas/AccountStatus" }
        balance_sun: { $ref: "#/components/schemas/Sun" }
        balance_usdt:
          type: integer
          format: int64
          description: USDT balance in the token's smallest unit (6 decimals).
        reserved_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Held against in-flight orders. Not spendable.
        deposit_addresses:
          type: array
          items: { $ref: "#/components/schemas/DepositAddress" }
        limits:
          type: object
          properties:
            max_order_energy: { type: integer }
            max_batch_receivers: { type: integer }
            orders_per_second: { type: integer }
        totals:
          type: object
          description: Lifetime counters. Informational; not a substitute for the ledger.
          properties:
            deposited_sun: { $ref: "#/components/schemas/Sun" }
            spent_sun: { $ref: "#/components/schemas/Sun" }
            refunded_sun: { $ref: "#/components/schemas/Sun" }
            orders_created: { type: integer }
            energy_delegated: { type: integer, format: int64 }
        created_at: { type: string, format: date-time }

    AccountPatch:
      type: object
      minProperties: 1
      properties:
        display_name:
          type: [string, "null"]
          maxLength: 64
          description: |
            The account's title. Trimmed; must not be blank and must carry no control
            characters. `null` clears it.
          examples: ["Payouts, production"]

    # ---- dashboard session

    SessionRequest:
      type: object
      required: [address, nonce, signature]
      properties:
        address: { $ref: "#/components/schemas/TronAddress" }
        nonce:
          type: string
          description: From `POST /v1/accounts/challenge`. Single use.
        signature:
          type: string
          description: The wallet's signature over the challenge `message`.

    Session:
      type: object
      required: [account_id, account_status, address, role, csrf_token, expires_at]
      properties:
        account_id:
          type: string
          description: |
            For the "Account ID (for support)" field. Nothing in a UI has to show it anywhere
            else — the account's title is `display_name`, or the shortened `owner_address`.
          examples: ["acc_01J9Z4K2M7Q8"]
        account_status: { $ref: "#/components/schemas/AccountStatus" }
        display_name: { type: [string, "null"] }
        owner_address:
          oneOf: [{ $ref: "#/components/schemas/TronAddress" }, { type: "null" }]
          description: The wallet that owns this account; signing in with it is how it is recovered.
        address:
          allOf: [{ $ref: "#/components/schemas/TronAddress" }]
          description: The address that signed in. The owner's, unless a member was invited.
        role:
          type: [string, "null"]
          enum: [owner, editor, viewer, null]
          description: This signer's role on the account. It, and nothing else, decides what they may do.
        network: { type: string, enum: [mainnet, nile] }
        csrf_token:
          type: string
          description: Echo in `X-CSRF-Token` on every POST, PATCH and DELETE of this session.
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        ttl_seconds:
          type: integer
          description: The full session lifetime, in seconds, as renewal resets it.
          examples: [2592000]

    Balance:
      type: object
      required: [balance_sun, available_sun, as_of]
      properties:
        balance_sun: { $ref: "#/components/schemas/Sun" }
        balance_usdt: { type: integer, format: int64 }
        reserved_sun: { $ref: "#/components/schemas/Sun" }
        available_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: "`balance_sun - reserved_sun`. This is what an order can draw on."
        as_of: { type: string, format: date-time }

    DepositAddress:
      type: object
      required: [currency, address]
      properties:
        currency: { type: string, enum: [TRX, USDT] }
        address: { $ref: "#/components/schemas/TronAddress" }
        memo:
          type: [string, "null"]
          description: |
            Always `null` since 2026-09-26: every account has an address of its own, so no memo
            is needed and any memo is ignored. Kept so existing clients do not break.
        confirmations_required:
          type: integer
          description: Blocks waited before the deposit is credited.
          examples: [19]
        contract:
          type: [string, "null"]
          description: The TRC-20 contract accepted at this address (`USDT` row only; `null` for TRX).
        rate_now:
          description: |
            `USDT` row only. The current SunSwap (or fixed) TRX-per-USDT rate BEFORE the spread,
            cached 60 s. `null` when no rate is available right now; the deposit is still accepted
            and the worker prices it at credit time. Indicative: the credit uses the rate the
            worker reads when it credits, not this one.
          oneOf:
            - type: "null"
            - type: object
              required: [trx_per_usdt, source, at]
              properties:
                trx_per_usdt: { type: string, examples: ["2.9672"] }
                source: { type: string, enum: [sunswap_v3, fixed_env] }
                at: { type: string, format: date-time }
        spread_bps:
          type: [integer, "null"]
          description: "`USDT` row only: basis points taken off the rate (5 = 0.05 %)."
          examples: [5]
        min:
          type: [string, "null"]
          description: "`USDT` row only: smallest credited USDT deposit; smaller ones are held (`held_below_min`)."
          examples: ["5"]
        max:
          type: [string, "null"]
          description: "`USDT` row only: largest auto-credited USDT deposit; larger ones are held (`held_for_review`)."
          examples: ["2000"]

    # ---- pricing

    MarketBoard:
      type: object
      required: [as_of, burn_sun, providers, summary]
      properties:
        as_of: { type: string, format: date-time }
        burn_sun: { type: number, description: SUN burned per energy unit without rental (100). }
        providers:
          type: array
          items:
            type: object
            required: [slug, name, rank, price_sun_1h, price_sun_1d, savings_pct, available_energy, total_energy, kinds, links, ts, stale]
            properties:
              slug: { type: string, examples: [itrx] }
              name: { type: string }
              rank: { type: [integer, "null"], description: 1 = cheapest 1h price; null when unpriced. }
              price_sun_1h: { type: [number, "null"] }
              price_sun_1d: { type: [number, "null"] }
              savings_pct: { type: [number, "null"], description: "(1 − price_sun_1h / burn_sun) × 100, two decimals." }
              available_energy: { type: [integer, "null"] }
              total_energy: { type: [integer, "null"] }
              kinds: { type: array, items: { type: string, enum: [api, bot, pool, market, web] } }
              links:
                type: object
                properties:
                  site: { type: [string, "null"] }
                  telegram: { type: [string, "null"] }
                  twitter: { type: [string, "null"] }
                  github: { type: [string, "null"] }
                  docs: { type: [string, "null"] }
                  referral: { type: [string, "null"], description: "Owner's referral link to the provider; null when there is none and on our own row." }
                  logo: { type: [string, "null"], description: "Provider logo URL verified on its own site (apple-touch-icon, svg icon, icon or og:image answering 200 with an image type); null when none was found and on our own row." }
              ts: { type: [string, "null"], format: date-time }
              stale: { type: boolean }
        summary:
          type: object
          required: [avg_price_sun_1h, active_providers]
          properties:
            avg_price_sun_1h: { type: [number, "null"], description: Mean 1h price over fresh priced rows. }
            active_providers: { type: integer }
    PriceTable:
      type: object
      required: [network, as_of, valid_until, items, subscriptions_available]
      properties:
        network: { type: string, enum: [mainnet, nile] }
        subscriptions_available:
          type: boolean
          description: |
            Whether a subscription can be created now. False while the deployment has
            subscriptions switched off; existing subscriptions keep running and stay manageable.
        as_of: { type: string, format: date-time }
        valid_until:
          type: string
          format: date-time
          description: |
            When the quoted prices may change. Re-read after this instant; do not cache past it.
        period:
          type: object
          description: |
            The pricing period currently in force. Periods are configured server-side and their
            number, labels and boundaries change — iterate, never hardcode.
          properties:
            id: { type: string, examples: ["off_peak"] }
            label: { type: string }
            start: { type: string, description: "HH:MM UTC", examples: ["01:00"] }
            end: { type: string, description: "HH:MM UTC", examples: ["09:00"] }
        schedule:
          type: array
          description: |
            Every pricing period of the day, ordered by start, with the energy `1h` price in each
            — enough to draw the whole day's price map in the reader's own time zone. The
            periods are configured server-side and change over time (a new version applies
            from its announced instant): iterate, never hardcode their number, ids or bounds.
            Together they cover the 24 h once. A period whose `end_utc_minute` is below its
            `start_utc_minute` runs across midnight UTC. Informational, like the rest of this
            table: a quote is what pins a price.
          items:
            type: object
            required: [id, label, start_utc_minute, end_utc_minute, factor_bps, price_sun_per_unit]
            properties:
              id: { type: string, examples: ["peak"] }
              label: { type: string, examples: ["Peak"] }
              start_utc_minute:
                type: integer
                minimum: 0
                maximum: 1439
                description: Inclusive start, minutes after 00:00 UTC.
              end_utc_minute:
                type: integer
                minimum: 1
                maximum: 1440
                description: Exclusive end, minutes after 00:00 UTC.
              factor_bps:
                type: integer
                minimum: 10000
                maximum: 30000
                description: The time-of-day factor of this period, basis points (10,000 = ×1.00).
              price_sun_per_unit:
                type: [number, "null"]
                description: |
                  Energy `1h`, SUN per unit, for an order placed in this period, a multiple of
                  0.01. `null` when the tier is not on sale in that period.
        available_energy:
          type: integer
          format: int64
          description: |
            Energy the platform can sell right now — the order book's sellable depth for the `1h`
            tier, the same figure `GET /orderbook` publishes. `0` while the book is empty.
        delivered_today:
          type: integer
          format: int64
          description: |
            Energy delivered on this brand's orders since 00:00 UTC today (sum of
            `delivered_amount` over confirmed, active, expired and reclaimed orders). A site may
            show it as "energy for N transfers delivered today" with N = value ÷ 65,000.
        payment_addresses:
          type: object
          description: |
            Where the no-account "send TRX" path pays, per tier, on this brand: send TRX to the
            tier's address with the receiving address in the memo (empty memo: the sender), and
            the transfer becomes an energy order for that receiver, priced when it arrives. The
            part of a payment that does not buy a whole 1,000-unit step is kept. Keys are tier
            ids; a tier without a published address is absent, and the object is empty when the
            brand publishes none. These are the addresses the platform
            watches, so a listed address is one a transfer can be matched on. The `1d` entry is
            omitted while that tier is switched off; a transfer that still reaches its address is
            refunded to the sender minus the network fee, not filled.
          propertyNames: { $ref: "#/components/schemas/Tier" }
          additionalProperties: { $ref: "#/components/schemas/TronAddress" }
          examples:
            - "5m": TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7
              "1h": TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf
        available_bandwidth: { type: integer, format: int64 }
        items:
          type: array
          items:
            type: object
            required: [resource, tier, price_sun_per_unit, min_amount, max_amount, available]
            properties:
              resource: { $ref: "#/components/schemas/ResourceType" }
              tier: { $ref: "#/components/schemas/Tier" }
              available:
                type: boolean
                description: |
                  Whether this row can be bought now. False for energy `1d` while that tier is
                  switched off: the price is shown, an order for it is `2003 tier_unavailable`.
              price_sun_per_unit:
                type: integer
                description: SUN per one unit of the resource for the whole tier period.
              min_amount: { type: integer }
              max_amount: { type: integer }
              volume_tiers:
                type: array
                description: Cheaper rates above a threshold. The highest matching entry wins.
                items:
                  type: object
                  required: [min_amount, price_sun_per_unit]
                  properties:
                    min_amount: { type: integer }
                    price_sun_per_unit: { type: integer }
        activation:
          type: object
          description: Cost of activating an inactive receiver, charged on top of an order.
          properties:
            price_sun: { $ref: "#/components/schemas/Sun" }

    OrderBookLevel:
      type: object
      required: [price_sun, amount, class]
      properties:
        price_sun:
          type: number
          description: SUN per unit for this level, a multiple of 0.01.
        amount:
          type: integer
          description: Units available at this price.
        class:
          type: string
          enum: [instant, market, deep]
          description: |
            How the energy reaches you: `instant` from the platform's own capacity, `market`
            bought on the wholesale market, `deep` from the on-chain market. Never a supplier
            name - the book publishes the class and nothing else.

    OrderBookFill:
      type: object
      required: [price_sun, amount, class]
      properties:
        price_sun: { type: number }
        amount: { type: integer }
        class: { type: string, enum: [instant, market, deep] }

    OrderBookWalk:
      type: object
      description: What a given amount would cost, level by level.
      required: [amount, fills, unit_price_sun, total_sun, complete]
      properties:
        amount:
          type: integer
          description: The requested amount, rounded up to the 1,000-unit step.
        fills:
          type: array
          items: { $ref: "#/components/schemas/OrderBookFill" }
        unit_price_sun:
          type: number
          description: The volume-weighted price over the fills, SUN per unit.
        total_sun: { $ref: "#/components/schemas/Sun" }
        complete:
          type: boolean
          description: |
            `false` when the ladder is shallower than the amount asked for. The `fills` then
            cover only what the book can deliver right now.
        outstanding:
          type: integer
          description: Units the ladder could not cover. `0` whenever `complete` is true.

    OrderBook:
      type: object
      required: [resource, tier, as_of, valid_until, floor_sun, depth, levels]
      properties:
        resource: { type: string, enum: [energy, bandwidth] }
        tier: { $ref: "#/components/schemas/Tier" }
        as_of: { type: string, format: date-time }
        valid_until:
          type: string
          format: date-time
          description: The book is rebuilt every few seconds; do not cache past this instant.
        floor_sun:
          type: number
          description: No level is published below this price, SUN per unit.
        depth:
          type: integer
          description: Everything the ladder can deliver right now, in units.
        levels:
          type: array
          items: { $ref: "#/components/schemas/OrderBookLevel" }
        walk:
          oneOf:
            - { $ref: "#/components/schemas/OrderBookWalk" }
            - type: "null"
          description: Present only when `amount` was supplied.
        cheaper_from:
          type: [string, "null"]
          format: date-time
          description: |
            When the next cheaper pricing period begins, or `null` when the current one is
            already the cheapest of the day. Render it in the buyer's own clock.

    Estimate:
      type: object
      required: [resource, amount, tier, price_sun_per_unit, total_amount_sun, as_of]
      properties:
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount: { type: integer }
        tier: { $ref: "#/components/schemas/Tier" }
        receiver: { oneOf: [{ $ref: "#/components/schemas/TronAddress" }, { type: "null" }] }
        price_sun_per_unit: { type: integer }
        energy_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Cost of the resource itself, before activation.
        activate_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: "`0` when the receiver is already active or was not supplied."
        total_amount_sun: { $ref: "#/components/schemas/Sun" }
        receiver_activated:
          type: [boolean, "null"]
          description: "`null` when `receiver` was not supplied."
        as_of: { type: string, format: date-time }

    QuoteRequest:
      type: object
      required: [resource, tier]
      properties:
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount:
          type: integer
          description: Required for `energy` and `bandwidth`; ignored for `activation`.
        tier: { $ref: "#/components/schemas/Tier" }
        receiver: { $ref: "#/components/schemas/TronAddress" }

    Quote:
      type: object
      required: [id, resource, tier, total_amount_sun, created_at, expires_at]
      properties:
        id: { type: string }
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount: { type: integer }
        tier: { $ref: "#/components/schemas/Tier" }
        receiver: { oneOf: [{ $ref: "#/components/schemas/TronAddress" }, { type: "null" }] }
        price_sun_per_unit: { type: integer }
        unit_price_sun:
          type: number
          description: |
            The volume-weighted price this quote was struck at, SUN per unit - the exact
            number behind `energy_amount_sun`, to the 0.01 SUN step. `price_sun_per_unit` is
            the same value rounded to a whole SUN and is kept for older clients.
        fills:
          type: array
          description: |
            How the quote walks the ask ladder: which part of the amount comes from which
            class, and at what price. Empty when the amount was priced at a single rate.
          items: { $ref: "#/components/schemas/OrderBookFill" }
        energy_amount_sun: { $ref: "#/components/schemas/Sun" }
        activate_amount_sun: { $ref: "#/components/schemas/Sun" }
        total_amount_sun: { $ref: "#/components/schemas/Sun" }
        created_at: { type: string, format: date-time }
        expires_at:
          type: string
          format: date-time
          description: |
            After this instant the quote is dead and an order referencing it is rejected with
            `3005 quote_expired`. The quote TTL is **120 seconds** — long enough to survive
            human latency on a quick-buy flow. Read this field rather than assuming the
            number.

    # ---- orders

    OrderRequest:
      type: object
      description: |
        Either supply `quote_id` alone, or describe the order with `resource` + `amount` + `tier`
        + `receiver`. Supplying both is allowed only when they agree; a mismatch is rejected with
        `2004 quote_mismatch`.
      properties:
        client_order_id:
          type: string
          maxLength: 64
          pattern: "^[A-Za-z0-9._:-]+$"
          description: |
            Your own id for this order, unique per account. Strongly recommended on every create:
            it makes the call idempotent and lets you fetch the order later without storing ours
            (`GET /v1/orders/cid:<client_order_id>`).
        quote_id:
          type: string
          description: A quote from `POST /v1/quotes`. Pins the price.
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount:
          type: integer
          minimum: 1
          description: Energy or bandwidth units. Omit for `activation`.
        tier: { $ref: "#/components/schemas/Tier" }
        receiver: { $ref: "#/components/schemas/TronAddress" }
        activate:
          type: boolean
          default: true
          description: |
            Activate the receiver if it is not active on chain, adding the activation fee to the
            charge. With `false`, an inactive receiver causes `3004 receiver_not_activated` and
            nothing is charged.
        max_price_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: |
            Refuse the order if the total (resource + activation) would exceed this. Your
            protection against a price move between estimate and order when you are not using a
            quote. Rejected with `3006 price_above_limit`.
        memo:
          type: [string, "null"]
          maxLength: 256
          description: Free-text note stored with the order and echoed back. Not sent on chain.

    Order:
      type: object
      required:
        [id, account_id, resource, receiver, status, confirm_status, total_amount_sun, created_at]
      properties:
        id: { type: string, examples: ["ord_01J9Z5P8T3WQ"] }
        client_order_id: { type: [string, "null"] }
        account_id: { type: string }
        batch_id:
          type: [string, "null"]
          description: Set when the order was produced by a batch.
        subscription_id:
          type: [string, "null"]
          description: Set when the order was produced by a subscription refill.
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount:
          type: [integer, "null"]
          description: What was ordered.
        delivered_amount:
          type: [integer, "null"]
          description: |
            What was actually delegated. Equal to `amount` on a normal order; below it when
            `partial` is true. `null` until delivery. Mirrors `BatchItem.delivered_amount`.
        partial:
          type: boolean
          default: false
          description: |
            True when some allocations landed and some did not, so the receiver got
            `delivered_amount` instead of `amount` and the difference was refunded pro rata
            (see `refunded_amount_sun`). **Partial delivery is a field, not an order state**:
            the order still runs through `confirmed` → `active`. A client that ignores this
            field sees a delivered order, which is why it defaults to `false` and is always
            present once the order has been delivered.
        tier: { oneOf: [{ $ref: "#/components/schemas/Tier" }, { type: "null" }] }
        duration_seconds:
          type: [integer, "null"]
          description: The tier expressed in seconds, so a client need not parse the slug.
        receiver: { $ref: "#/components/schemas/TronAddress" }
        source: { $ref: "#/components/schemas/SourceType" }
        status: { $ref: "#/components/schemas/OrderStatus" }
        confirm_status: { $ref: "#/components/schemas/ConfirmStatus" }
        price_sun_per_unit: { type: [integer, "null"] }
        pay_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Charged for the resource itself.
        activate_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Charged for activating the receiver; `0` when no activation was needed.
        total_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: "`pay_amount_sun + activate_amount_sun`. What left the balance."
        refunded_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Credited back so far. Non-zero for `failed` and `refunded` orders.
        delegate_hash:
          oneOf: [{ $ref: "#/components/schemas/TxHash" }, { type: "null" }]
          description: |
            First delegation transaction. Convenience field — identical to `delegate_hashes[0]`.
            `null` until the delegation is broadcast.
        delegate_hashes:
          type: array
          description: |
            All delegation transactions for this order. More than one when the amount was filled
            from several stake addresses. Always present, possibly empty.
          items: { $ref: "#/components/schemas/TxHash" }
        delegated_at: { type: [string, "null"], format: date-time }
        reclaim_hash: { oneOf: [{ $ref: "#/components/schemas/TxHash" }, { type: "null" }] }
        reclaimed_at: { type: [string, "null"], format: date-time }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: When the rental window ends. `null` until delegation.
        activation:
          type: object
          description: What happened about activating the receiver.
          properties:
            performed: { type: boolean }
            hash: { oneOf: [{ $ref: "#/components/schemas/TxHash" }, { type: "null" }] }
            amount_sun: { $ref: "#/components/schemas/Sun" }
        memo: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        failure:
          description: Present and non-null only for `failed` orders.
          oneOf:
            - type: "null"
            - type: object
              required: [code, slug, message]
              properties:
                code: { type: integer }
                slug: { type: string }
                message: { type: string }
                at: { type: string, format: date-time }

    # ---- batches

    BatchRequest:
      type: object
      required: [items]
      properties:
        client_batch_id:
          type: string
          minLength: 8
          maxLength: 128
          pattern: "^[A-Za-z0-9._:-]+$"
          description: |
            Your reference for the batch and its idempotency key. Required unless you send the
            `Idempotency-Key` header; with neither the request is rejected with
            `2005 idempotency_key_required`.
        defaults:
          $ref: "#/components/schemas/BatchItemOptions"
          description: Applied to every item that does not override the field itself.
        items:
          type: array
          minItems: 1
          maxItems: 100
          description: Duplicate receivers within one batch are rejected with `2006 duplicate_receiver`.
          items:
            allOf:
              - type: object
                required: [receiver]
                properties:
                  receiver: { $ref: "#/components/schemas/TronAddress" }
              - $ref: "#/components/schemas/BatchItemOptions"

    BatchItemOptions:
      type: object
      description: Per-receiver options; every one of them may also be set in `defaults`.
      properties:
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount: { type: integer }
        tier: { $ref: "#/components/schemas/Tier" }
        activate: { type: boolean, default: true }
        bandwidth:
          type: boolean
          default: true
          description: Top the receiver's bandwidth up when it is short, before delivering energy.
        bandwidth_amount:
          type: integer
          default: 400
          description: Bandwidth units to add when the top-up runs.
        max_price_sun: { $ref: "#/components/schemas/Sun" }
        client_order_id_prefix:
          type: string
          description: |
            When set, each generated order gets `client_order_id = "<prefix>-<receiver>"`, so your
            side can reconcile without keeping our ids.

    Batch:
      type: object
      required: [id, status, items_accepted, summary, items, created_at]
      properties:
        id: { type: string, examples: ["bat_01J9Z6Q1V8XZ"] }
        client_batch_id: { type: [string, "null"] }
        status:
          type: string
          enum: [queued, processing, completed, partial, failed, cancelled]
          description: |
            The batch's own state, derived from its items. `partial` means some receivers
            succeeded and some did not — inspect `items`, never assume all-or-nothing.
        items_accepted: { type: integer }
        summary:
          type: object
          description: Counts by item status. Cheaper to poll than the full item list.
          properties:
            total: { type: integer }
            queued: { type: integer }
            processing: { type: integer }
            completed: { type: integer }
            partial: { type: integer }
            failed: { type: integer }
            insufficient_funds: { type: integer }
            cancelled: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/BatchItem" }
        created_at: { type: string, format: date-time }
        finished_at: { type: [string, "null"], format: date-time }

    BatchItem:
      type: object
      required: [receiver, tracking_id, status]
      properties:
        receiver: { $ref: "#/components/schemas/TronAddress" }
        tracking_id:
          type: string
          description: "`<client_batch_id>:<receiver>` — the identity of one receiver in one batch."
        status:
          type: string
          enum: [queued, processing, completed, partial, failed, insufficient_funds, cancelled]
        resource: { $ref: "#/components/schemas/ResourceType" }
        amount: { type: integer }
        tier: { $ref: "#/components/schemas/Tier" }
        delivered_amount:
          type: integer
          description: How much was actually delegated. Below `amount` when `status` is `partial`.
        order_ids:
          type: array
          description: |
            The orders created for this receiver — more than one when a large amount was chunked.
            These are ordinary orders: inspect and reclaim them individually.
          items: { type: string }
        delegate_hashes:
          type: array
          items: { $ref: "#/components/schemas/TxHash" }
        charged_amount_sun: { $ref: "#/components/schemas/Sun" }
        activation:
          type: object
          properties:
            status: { type: string, enum: [planned, not_needed, done, failed, skipped] }
            hash: { oneOf: [{ $ref: "#/components/schemas/TxHash" }, { type: "null" }] }
        bandwidth:
          type: object
          properties:
            status: { type: string, enum: [planned, enough, done, failed, skipped] }
            order_id: { type: [string, "null"] }
            skip_reason:
              type: [string, "null"]
              enum: [option_off, amount_large, null]
              description: |
                Why the bandwidth step did not run. `option_off` — you disabled it;
                `amount_large` — a large energy order does not need a bandwidth top-up.
        attempts: { type: integer }
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        failure:
          oneOf:
            - type: "null"
            - type: object
              properties:
                code: { type: integer }
                slug: { type: string }
                message: { type: string }

    # ---- subscriptions

    SubscriptionRequest:
      type: object
      required: [receiver, resource, mode, tier, threshold_amount, refill_amount]
      properties:
        receiver: { $ref: "#/components/schemas/TronAddress" }
        resource: { type: string, enum: [energy, bandwidth] }
        mode:
          type: string
          enum: [refill, renewal]
          description: |
            `refill` tops up when the address falls below the threshold; `renewal` keeps a standing
            rental alive by re-ordering as it expires.
        tier: { $ref: "#/components/schemas/Tier" }
        threshold_amount:
          type: integer
          description: Refill when free resource on the address drops below this.
        refill_amount:
          type: integer
          description: How much to buy on each refill.
        max_price_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: |
            Skip a refill whose total would exceed this rather than paying a spike price. A skipped
            refill produces no order and no webhook; the next check tries again.
        max_refills_per_day:
          type: integer
          description: |
            Hard stop against a runaway address draining the balance. Once reached, refills pause
            until the next UTC day. Strongly recommended.
        label: { type: [string, "null"], maxLength: 128 }

    Subscription:
      type: object
      required: [id, receiver, resource, mode, tier, status, created_at]
      properties:
        id: { type: string }
        receiver: { $ref: "#/components/schemas/TronAddress" }
        resource: { type: string, enum: [energy, bandwidth] }
        mode: { type: string, enum: [refill, renewal] }
        tier: { $ref: "#/components/schemas/Tier" }
        threshold_amount: { type: integer }
        refill_amount: { type: integer }
        max_price_sun: { oneOf: [{ $ref: "#/components/schemas/Sun" }, { type: "null" }] }
        max_refills_per_day: { type: [integer, "null"] }
        daily_fee_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Watch fee charged per calendar day while the subscription is not cancelled.
        status: { $ref: "#/components/schemas/SubscriptionStatus" }
        label: { type: [string, "null"] }
        last_refill_at: { type: [string, "null"], format: date-time }
        last_order_id: { type: [string, "null"] }
        refills_today: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        address:
          allOf: [{ $ref: "#/components/schemas/TronAddress" }]
          description: Same as `receiver`. Present only on plan subscriptions, with the fields below.
        preset: { type: [string, "null"], description: Preset slug; null for a custom rule. }
        reserve: { type: [integer, "null"], description: Energy kept delegated at most. }
        low: { type: [integer, "null"], description: Refill when available energy falls below this. }
        high: { type: [integer, "null"], description: Refill up to this. }
        fee_trx_per_day: { type: number }
        delegated_energy: { type: integer, description: Energy delegated to the address now. }
        next_billing_at: { type: [string, "null"], format: date-time }
        grace_until:
          type: [string, "null"]
          format: date-time
          description: Set while a failed daily charge keeps the energy delegated (reported as `suspended`).
        events:
          type: array
          description: "`GET /v1/subscriptions/{id}` of a plan subscription only: the last 20 events, newest first."
          items: { $ref: "#/components/schemas/SubscriptionEvent" }

    SubscriptionEvent:
      type: object
      required: [id, kind, ts]
      properties:
        id: { type: string }
        kind: { type: string, enum: [refill, charge, pause, resume, cancel, top_up_failed] }
        energy_delta: { type: [integer, "null"] }
        amount_sun: { type: [integer, "null"] }
        txid: { type: [string, "null"] }
        ts: { type: string, format: date-time }

    PlanSubscriptionRequest:
      type: object
      description: "`address` and either `preset` or all of `reserve`, `low`, `high` (subscriptions.md §1). `receiver` is accepted for `address`."
      properties:
        address: { $ref: "#/components/schemas/TronAddress" }
        receiver: { $ref: "#/components/schemas/TronAddress" }
        preset: { type: string, description: "Slug from `GET /v1/subscriptions/plans`." }
        reserve: { type: integer }
        low: { type: integer }
        high: { type: integer }
        label: { type: [string, "null"], maxLength: 128 }

    PlanSubscriptionPatch:
      type: object
      description: "Exactly one of: `reserve` + `low` + `high`, `preset`, or `status`."
      properties:
        reserve: { type: integer }
        low: { type: integer }
        high: { type: integer }
        preset: { type: string }
        status: { type: string, enum: [active, paused] }

    SubscriptionPlan:
      type: object
      required: [slug, name, reserve, low, high, fee_sun_per_day, fee_trx_per_day, throughput_rule, uses_within_reserve_per_day, average_price]
      properties:
        slug: { type: string }
        name: { type: string }
        reserve: { type: integer, description: Energy kept delegated to the address at most. }
        low: { type: integer, description: Refill when available energy falls below this. }
        high: { type: integer, description: Refill up to this. }
        fee_sun_per_day: { type: integer }
        fee_trx_per_day: { type: number }
        throughput_rule: { type: string }
        uses_within_reserve_per_day: { type: integer }
        average_price:
          type: array
          description: Average price per use = daily fee / N + per-use price, for N = 10, 50, 200, 1,000.
          items:
            type: object
            required: [uses_per_day, average_sun, average_trx, within_reserve]
            properties:
              uses_per_day: { type: integer }
              average_sun: { type: [integer, "null"] }
              average_trx: { type: [number, "null"] }
              within_reserve: { type: boolean }

    SubscriptionPatch:
      type: object
      minProperties: 1
      properties:
        status: { type: string, enum: [active, paused] }
        tier: { $ref: "#/components/schemas/Tier" }
        threshold_amount: { type: integer }
        refill_amount: { type: integer }
        max_price_sun: { $ref: "#/components/schemas/Sun" }
        max_refills_per_day: { type: integer }
        label: { type: [string, "null"] }

    # ---- webhooks

    WebhookRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS, publicly resolvable, no credentials.
        role:
          type: string
          enum: [primary, backup]
          description: Omit to take the first free role.
        events:
          type: array
          description: |
            Which events to deliver here. Omit to receive all of them — recommended, since new
            event types then start arriving without a registration change. Route by the `event`
            field and ignore types you do not handle yet.
          items: { $ref: "#/components/schemas/WebhookEventType" }

    WebhookEndpoint:
      type: object
      required: [id, url, role, is_active, created_at]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        role: { type: string, enum: [primary, backup] }
        events:
          type: [array, "null"]
          description: "`null` means all events."
          items: { $ref: "#/components/schemas/WebhookEventType" }
        is_active: { type: boolean }
        last_delivery_at: { type: [string, "null"], format: date-time }
        last_delivery_status: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookPatch:
      type: object
      minProperties: 1
      properties:
        url: { type: string, format: uri }
        role: { type: string, enum: [primary, backup] }
        events:
          type: [array, "null"]
          items: { $ref: "#/components/schemas/WebhookEventType" }
        is_active: { type: boolean }

    # ---- chain

    AddressResources:
      type: object
      required: [address, activated, energy, bandwidth, as_of]
      properties:
        address: { $ref: "#/components/schemas/TronAddress" }
        activated:
          type: boolean
          description: False for an address that has never received anything on chain.
        balance_sun: { $ref: "#/components/schemas/Sun" }
        holds_usdt:
          type: [boolean, "null"]
          description: |
            Whether the address holds a non-zero USDT (TRC-20) balance — `balanceOf > 0` on the
            network's USDT contract. A USDT transfer TO an address that holds none writes a new
            storage slot and costs about twice the energy (~131k instead of ~65k). `null` when
            the read failed; the rest of the response is unaffected.
        energy:
          type: object
          properties:
            limit: { type: integer, description: Total energy the address may use. }
            used: { type: integer }
            available: { type: integer, description: "`limit - used`. What a transfer can spend now." }
            delegated_in: { type: integer, description: Part of the limit that came from delegations. }
        bandwidth:
          type: object
          properties:
            limit: { type: integer }
            used: { type: integer }
            available: { type: integer }
            delegated_in: { type: integer }
            free_net_limit:
              type: integer
              description: The daily free bandwidth allowance, included in `limit`.
        our_active_orders:
          type: array
          description: |
            This account's own currently-active orders delivering to this address. Delegations from
            other providers or other accounts are counted in `delegated_in` but are not listed —
            we cannot attribute them.
          items:
            type: object
            properties:
              order_id: { type: string }
              amount: { type: integer }
              resource: { $ref: "#/components/schemas/ResourceType" }
              expires_at: { type: string, format: date-time }
        as_of:
          type: string
          format: date-time
          description: When this was read from the chain. Cached for a few seconds.

    LedgerEntry:
      type: object
      required: [id, kind, amount_sun, amount_usdt, order_id, deposit, created_at]
      properties:
        id:
          type: string
          description: The ledger journal id.
          examples: ["jrn_01K5Y7A1B2C3D4E5F6G7H8J9K0"]
        kind:
          type: string
          enum: [deposit, order_charge, order_refund, referral_payout, subscription_fee, subscription_usage, reserve_fee, invoice, withdrawal, adjustment]
        amount_sun:
          type: integer
          format: int64
          description: Net effect on the TRX balance in SUN; negative for a charge.
        amount_usdt:
          type: integer
          format: int64
          description: Net effect on the USDT balance in the token's smallest unit.
        order_id:
          type: [string, "null"]
          description: The order a charge or refund belongs to.
        deposit:
          description: Set for `kind = deposit`, `null` otherwise.
          oneOf:
            - type: "null"
            - type: object
              required: [txid, sender, block_number, status, confirmations_required]
              properties:
                txid: { type: [string, "null"], description: The deposit transaction hash. }
                sender:
                  type: [string, "null"]
                  description: The address the TRX came from; `null` on deposits credited before 2026-09-25.
                block_number: { type: [integer, "null"] }
                status:
                  type: string
                  enum: [credited, pending_rate, held_below_min, held_for_review]
                  description: |
                    `credited` for every journal. With `kind=deposit`, USDT deposits the worker saw
                    but did not credit are listed too (first page only): `held_below_min`,
                    `held_for_review` or `pending_rate`, with `amount_sun` 0 — nothing was credited.
                confirmations_required: { type: integer, examples: [19] }
                asset: { type: string, enum: [TRX, USDT] }
                usdt_amount:
                  type: [string, "null"]
                  description: USDT received (decimal string); `null` for TRX.
                  examples: ["25.000000"]
                rate:
                  type: [string, "null"]
                  description: SUN credited per 1 USDT, after the spread; `null` for TRX and held rows.
                  examples: ["2967051.600000"]
                rate_source:
                  type: [string, "null"]
                  description: Where the rate came from (`fixed_env`, `sunswap_v3@<block>`, ...); `null` for TRX.
                spread_bps: { type: [integer, "null"], examples: [5] }
        created_at: { type: string, format: date-time }

    AddressBookEntry:
      type: object
      required: [id, label, address, created_at, updated_at]
      properties:
        id: { type: string, examples: ["adr_01K5Y9B7C8D9E0F1G2H3J4K5M6"] }
        label: { type: string, minLength: 1, maxLength: 64 }
        address: { $ref: "#/components/schemas/TronAddress" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    AddressBookRequest:
      type: object
      required: [label, address]
      properties:
        label:
          type: string
          minLength: 1
          maxLength: 64
          description: Trimmed; 1–64 characters after trimming.
        address: { $ref: "#/components/schemas/TronAddress" }

    AccountStats:
      type: object
      required: [from, to, requested_from, clamped, retention_months, bucket, utc_offset_minutes, totals, series, weekday_hour, top_receivers]
      properties:
        from: { type: string, format: date-time, description: The start actually used, after clamping. }
        to: { type: string, format: date-time }
        requested_from: { type: string, format: date-time }
        clamped:
          type: boolean
          description: True when `from` was older than the retention window and was moved forward.
        retention_months: { type: integer, examples: [13] }
        bucket: { type: string, enum: [day] }
        utc_offset_minutes: { type: integer }
        totals:
          type: object
          required: [orders, energy, spent_sun, avg_price_sun_per_65k]
          properties:
            orders: { type: integer }
            energy: { type: integer }
            spent_sun: { $ref: "#/components/schemas/Sun" }
            avg_price_sun_per_65k: { type: [integer, "null"] }
        series:
          type: array
          items:
            type: object
            required: [date, orders, energy, spent_sun]
            properties:
              date: { type: string, format: date }
              orders: { type: integer }
              energy: { type: integer }
              spent_sun: { $ref: "#/components/schemas/Sun" }
        weekday_hour:
          type: array
          description: Seven rows (Monday first) of 24 hourly order counts.
          items:
            type: array
            items: { type: integer }
        top_receivers:
          type: array
          maxItems: 10
          items:
            type: object
            required: [receiver, orders, energy, spent_sun]
            properties:
              receiver: { $ref: "#/components/schemas/TronAddress" }
              orders: { type: integer }
              energy: { type: integer }
              spent_sun: { $ref: "#/components/schemas/Sun" }

    WebhookDelivery:
      type: object
      required: [id, event_id, event, attempt, state, response_status, error, next_attempt_at, delivered_at, created_at, updated_at]
      properties:
        id: { type: string }
        event_id:
          type: string
          description: The `id` of the event envelope — your dedup key, stable across retries.
        event: { $ref: "#/components/schemas/WebhookEventType" }
        attempt: { type: integer, description: Attempts made so far. }
        state:
          type: string
          enum: [pending, delivering, delivered, failed, dead]
          description: "`failed` is retried at `next_attempt_at`; `dead` is not retried."
        response_status:
          type: [integer, "null"]
          description: HTTP status of the last attempt; `null` before one, or on a connection failure.
        error: { type: [string, "null"], description: Transport failure of the last attempt. }
        next_attempt_at: { type: [string, "null"], format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SignIn:
      type: object
      required: [signed_in_at, ip, address]
      properties:
        signed_in_at: { type: string, format: date-time }
        ip: { type: [string, "null"] }
        address: { $ref: "#/components/schemas/TronAddress" }

    TransferEstimateRequest:
      type: object
      required: [from_address, to_address]
      properties:
        from_address: { $ref: "#/components/schemas/TronAddress" }
        to_address: { $ref: "#/components/schemas/TronAddress" }
        contract_address:
          allOf: [{ $ref: "#/components/schemas/TronAddress" }]
          description: TRC-20 contract. Defaults to USDT `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`.
        amount:
          type: [string, "null"]
          description: |
            Transfer amount in the token's smallest unit, as a decimal string to avoid precision
            loss on large values. Affects the simulation only marginally; omit if unknown.
        tier:
          allOf: [{ $ref: "#/components/schemas/Tier" }]
          description: Tier to price the rental at. Defaults to `1h`.

    TransferEstimate:
      type: object
      required: [from_address, to_address, contract_address, energy_required, recommended_amount, as_of]
      properties:
        from_address: { $ref: "#/components/schemas/TronAddress" }
        to_address: { $ref: "#/components/schemas/TronAddress" }
        contract_address: { $ref: "#/components/schemas/TronAddress" }
        recipient_holds_token:
          type: boolean
          description: |
            Whether the recipient already has a non-zero balance of this token. A first-time
            recipient roughly doubles the energy cost, which is the single biggest factor here.
        energy_required:
          type: integer
          description: Energy the simulated transfer consumed.
        recommended_amount:
          type: integer
          description: |
            What to actually order — `energy_required` plus a safety margin for state drift between
            this estimate and the broadcast. Order this, not `energy_required`.
        bandwidth_required: { type: integer }
        tier: { $ref: "#/components/schemas/Tier" }
        price_sun_per_unit: { type: integer }
        energy_amount_sun: { $ref: "#/components/schemas/Sun" }
        activate_amount_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: Activation fee for `to_address` when it is not yet activated.
        total_amount_sun: { $ref: "#/components/schemas/Sun" }
        burn_alternative_sun:
          allOf: [{ $ref: "#/components/schemas/Sun" }]
          description: |
            What the same transfer would cost in burned TRX at the current network energy
            price, for comparison. Computed from the chain parameter (`getEnergyFee`), not
            from a stored constant. That parameter has been **100 SUN per energy since
            2025-08-29**; the example is `130,285 × 100`. Any code or example still using 210
            is reading a stale hard-coded constant.
        as_of: { type: string, format: date-time }

    # ---- api keys

    ApiKeyScope:
      type: string
      description: |
        One permission name from the platform's single `area.action` vocabulary, restricted to
        the subset an API key may carry in v1. The full vocabulary — including
        `balance.withdraw`, `team.grant`, `audit.read` and the rest — exists outside this
        enum for other parts of the platform; extending this enum to any of those names is a
        deliberate change, not a routine contract edit.
      enum:
        - prices.read
        - balance.read
        - balance.topup_address
        - orders.read
        - orders.create
        - orders.reclaim
        - subscriptions.read
        - subscriptions.write
        - webhooks.read
        - webhooks.write
        - keys.read
        - keys.create
        - keys.revoke

    ApiKeyRequest:
      type: object
      required: [scopes]
      properties:
        label:
          type: string
          maxLength: 128
          description: |
            Optional. Omitted, the key is named `Key N` at the next free index, so several
            keys can be created without inventing names for them. A label is a name, not a
            permission — which is why it may have a default and `scopes` never will.
          examples: ["payouts worker"]
        scopes:
          type: array
          minItems: 1
          uniqueItems: true
          description: |
            Required, no default, no server-side suggestion. The names are the key-eligible
            subset of the one platform permission vocabulary; see the endpoint description
            for what each opens.
          items:
            $ref: "#/components/schemas/ApiKeyScope"
        ip_allowlist:
          type: array
          description: |
            Source addresses allowed to use this key, IPv4/IPv6 addresses or CIDR blocks. An empty
            or absent list means any IP — acceptable for a read-only key, a bad idea for one that
            can spend.
          items: { type: string }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: Automatic revocation time. `null` for a key that does not expire.

    NodeKey:
      type: object
      required: [id, label, active, created_at, last_used_at]
      properties:
        id: { type: string, examples: ["nk_0123456789abcdef0123456789abcdef"] }
        label: { type: [string, "null"] }
        active: { type: boolean }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }
    ApiKey:
      type: object
      required: [id, key, label, scopes, is_active, created_at]
      properties:
        id: { type: string, examples: ["key_01J9ZA1D9EFG"] }
        key:
          type: string
          description: |
            The public identifier sent in `X-API-KEY`. Not secret.

            The prefix encodes the **environment**: `ak_live_` on the production host,
            `ak_test_` on the Nile host, followed by 24 random bytes in base64url. A key sent
            to the host of the other environment is refused with `1012
            key_environment_mismatch` before it is even looked up, so pasting a test key into
            production fails with a sentence that says what happened rather than with
            "unknown key".
          examples: ["ak_live_8Qk3rN2vYb7pLmXaTc0dWfHj5sZgQe1U"]
        label: { type: string }
        scopes:
          type: array
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        ip_allowlist:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        last_used_at:
          type: [string, "null"]
          format: date-time
          description: |
            When this key last signed a request. Written at most once a minute per key, so it
            answers "is this key still in use", not "to the second, when".
        last_used_ip:
          type: [string, "null"]
          description: The source address of that last request. `null` until the key is used.
          examples: ["203.0.113.10"]
        expires_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    ApiKeyPatch:
      type: object
      minProperties: 1
      properties:
        label: { type: string, maxLength: 128 }
        scopes:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: "#/components/schemas/ApiKeyScope" }
        ip_allowlist:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        expires_at: { type: [string, "null"], format: date-time }
