openapi: 3.1.0
info:
  title: TradeOn Merchant API
  version: "1.0.0"
  summary: B2B HTTP API for buying CS2 skins through TradeOn Market.
  description: |
    The Merchant API lets a partner integrate their own marketplace, mini-app, or backend
    with TradeOn Market. A merchant can browse the live CS2 catalogue, place purchases that
    are delivered directly to their end-users on Steam, and receive webhook notifications
    when an order changes state.

    ## Authentication

    Every request authenticates with an API key:

    ```
    Authorization: Bearer <api_key>
    ```

    The key is issued in the merchant cabinet at `merchant.tradeon.market` after onboarding.
    Treat it like a password — HTTPS only, never commit, never log. Rotation is performed
    in the cabinet (`Settings → Regenerate API key`).

    ## Conventions

    - All requests/responses are `application/json` unless noted.
    - Money amounts are USD and serialised as JSON numbers. Use decimal arithmetic in clients.
    - Timestamps are ISO 8601 UTC with `Z` suffix (`2026-05-05T16:00:00.000Z`).
    - Errors follow a stable shape: `{ "error": { "code": "MERCHANT_…", "message": "…" } }`.
      The `code` is the contract — match on it programmatically. `message` is human prose.
  contact:
    name: TradeOn Merchant API support
    url: https://merchant.tradeon.market/docs
servers:
  - url: https://merchant-api.tradeon.market
    description: Production
tags:
  - name: Balance
    description: Available balance, on-hold orders, deposit addresses.
  - name: Catalog
    description: Item listing, search, full-dump.
  - name: Purchase
    description: Place and inspect purchases.
  - name: Webhook events
    description: Inspect outgoing webhook deliveries.
security:
  - apiKey: []
paths:
  # --------------------- Balance ---------------------
  /api/v1/merchant/balance:
    get:
      operationId: getBalance
      tags: [Balance]
      summary: Available, on-hold, lifetime totals + deposit addresses.
      security:
        - apiKey: []
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/merchant/deposits/wallets:
    get:
      operationId: listDepositWallets
      tags: [Balance]
      summary: Read locally saved Whitepay wallets for the authenticated merchant.
      description: Does not call the provider or create wallets. Issuance is disabled by default.
      responses:
        "200":
          description: Availability and saved requests, including pending states.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositWalletList" }
              example: { issuance_enabled: false, can_create: false, wallets: [] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      operationId: issueDepositWallet
      tags: [Balance]
      summary: Explicitly request one Whitepay USDT address for a network.
      description: |
        Disabled by default. Merchant identity comes from authentication.
        One durable request per merchant/network. Once creation starts, repeated calls
        return the saved attempt without another provider POST. A 200 can be pending.
        After a lost response read the wallet list; do not create a replacement.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DepositWalletRequest" }
            example: { network: tron }
      responses:
        "200":
          description: Saved wallet or pending creation request.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositWallet" }
        "400":
          description: MERCHANT_WALLET_NETWORK_INVALID; malformed JSON/body may use HTTP validation problem details.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: MERCHANT_WALLET_ISSUANCE_DISABLED. Read saved state before continuing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: MERCHANT_WALLET_PROVIDER_UNAVAILABLE. Read saved state before continuing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/merchant/deposits/wallets/{id}/reconcile:
    post:
      operationId: reconcileDepositWallet
      tags: [Balance]
      summary: Recover pending creation or refresh the saved provider status using GETs only.
      description: |
        Remains available with new issuance disabled. Does not create, activate or
        deactivate a provider wallet. Bound wallet credentials must remain unchanged.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Saved wallet state, possibly still pending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositWallet" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: MERCHANT_WALLET_NOT_FOUND, also for another merchant's wallet.
        "409":
          description: WALLET_CREATION_NOT_STARTED. Explicit creation is needed first.
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: MERCHANT_WALLET_PROVIDER_UNAVAILABLE. A missing/ambiguous result never permits a replacement POST.

  /api/v1/merchant/deposits/fees:
    get:
      operationId: getDepositFees
      tags: [Balance]
      summary: Read the deposit commission schedule.
      responses:
        "200":
          description: Commission tiers in USD; lower bound inclusive, upper bound exclusive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositFees" }
              example:
                currency: USD
                tiers:
                  - { min_amount: 0, max_amount: 5000, percent: 1.0 }
                  - { min_amount: 5000, max_amount: null, percent: 0.5 }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/merchant/deposits:
    get:
      operationId: listDeposits
      tags: [Balance]
      summary: Confirmed merchant deposits, including retained Heleket history.
      description: Whitepay support is not enabled yet. Only confirmed merchant credits appear here.
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque next_cursor from the preceding response.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Deposits ordered by creation time descending, then ID ascending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositList" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  # --------------------- Catalog ---------------------
  /api/v1/merchant/items:
    get:
      operationId: listItems
      tags: [Catalog]
      summary: Paginated catalogue listing with cursor.
      description: |
        Cursor-paginated. Pass `cursor` from the previous response's `next_cursor` to walk
        the listing. Provider economics (`provider_id`, `provider_price`, markup) are
        intentionally absent — only the price you would pay is exposed.
      security:
        - apiKey: []
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: market_hash_name
          schema: { type: string }
        - in: query
          name: min_price
          schema: { type: number, format: decimal }
        - in: query
          name: max_price
          schema: { type: number, format: decimal }
        - in: query
          name: category
          schema: { type: string }
        - in: query
          name: exterior
          schema: { type: string }
        - in: query
          name: rarity
          schema: { type: string }
        - in: query
          name: min_float
          schema: { type: number, format: decimal }
        - in: query
          name: max_float
          schema: { type: number, format: decimal }
        - in: query
          name: delivery_type
          schema:
            type: string
            enum: [instant, manual]
        - in: query
          name: sort
          schema:
            type: string
            enum: [price_asc, price_desc]
            default: price_asc
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogList" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "400":
          description: MERCHANT_INVALID_SORT — unsupported filter value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/merchant/items/search:
    get:
      operationId: searchItems
      tags: [Catalog]
      summary: Search by exact `market_hash_name` (price/float filtering).
      description: |
        Convenience endpoint for "find me a copy of this skin under $X with float in
        [Y, Z]".
      security:
        - apiKey: []
      parameters:
        - in: query
          name: market_hash_name
          required: true
          schema: { type: string }
        - in: query
          name: max_price
          schema: { type: number, format: decimal }
        - in: query
          name: min_float
          schema: { type: number, format: decimal }
        - in: query
          name: max_float
          schema: { type: number, format: decimal }
        - in: query
          name: delivery_type
          schema:
            type: string
            enum: [instant, manual]
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogSearch" }
        "400":
          description: "`market_hash_name` is missing."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/merchant/items/full-dump:
    get:
      operationId: getCatalogDump
      tags: [Catalog]
      summary: Full active catalogue (gzip-encoded JSON, ETag-driven).
      description: |
        Returns the entire active catalogue, normally refreshed about every two minutes.
        Use generated_at to judge freshness; regeneration timing is not guaranteed.
        Send the saved weak ETag verbatim (including W/ and quotes) in If-None-Match.
        A matching request returns 304 with no body; preserve the cached snapshot.

        ETag and, when available, the paired X-Catalog-Cursor/X-Catalog-Epoch are returned
        on both 200 and 304. Last-Modified and X-Items-Count are only returned on 200.
        The cursor is captured before snapshot generation so replay can reconcile changes.
        The pair is omitted if generation crosses an engine restart or the stream is unavailable.

        Finish downloading and validating a replacement before atomically publishing it with
        its ETag/cursor/epoch. Bootstrap or resync the current catalog WebSocket from this
        checkpoint, then replay and enter live processing. Without both checkpoint headers,
        keep REST synchronization until a fresh dump supplies them; never use the current
        stream head as a substitute. See https://merchant.tradeon.market/docs/realtime-feed.

        Content-Encoding is gzip when Accept-Encoding contains gzip. 503 FEED_NOT_READY
        means a usable dump is unavailable; retry with backoff, without assuming a readiness deadline.
      security:
        - apiKey: []
      parameters:
        - in: header
          name: If-None-Match
          schema: { type: string }
        - in: header
          name: Accept-Encoding
          schema: { type: string, example: "gzip" }
      responses:
        "200":
          description: Full dump.
          headers:
            ETag: { $ref: "#/components/headers/CatalogETag" }
            X-Catalog-Cursor: { $ref: "#/components/headers/CatalogCursor" }
            X-Catalog-Epoch: { $ref: "#/components/headers/CatalogEpoch" }
            Last-Modified: { schema: { type: string, format: http-date } }
            X-Items-Count: { schema: { type: integer } }
            Content-Encoding: { schema: { type: string, example: "gzip" } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CatalogFullDump" }
        "304":
          description: Not modified; no body. Keep the cached snapshot. Last-Modified and X-Items-Count are not returned.
          headers:
            ETag: { $ref: "#/components/headers/CatalogETag" }
            X-Catalog-Cursor: { $ref: "#/components/headers/CatalogCursor" }
            X-Catalog-Epoch: { $ref: "#/components/headers/CatalogEpoch" }
        "503":
          description: FEED_NOT_READY — no usable dump is currently available.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  # --------------------- Purchase ---------------------
  /api/v1/merchant/buy:
    post:
      operationId: createPurchase
      tags: [Purchase]
      summary: Place a purchase for a Steam end-user.
      description: |
        Creates one merchant purchase. Behaviour:

        - **Single item per call.** To buy multiple items, fan out client-side (each call
          is independent, debits the balance separately, and emits its own webhook chain).
        - **Price ceiling.** Only the actual item price is debited, never max_price.
          If the price exceeds your ceiling, the call returns 409 MERCHANT_PRICE_CHANGED.
        - **Idempotency via custom_id.** Persist your business key before the first POST.
          A matching repeat returns 201 with the existing purchase_id and current status
          and balance, without a second debit. Matching checks item, derived recipient,
          trade URL and whether max_price covers the saved price. A mismatch returns
          409 MERCHANT_DUPLICATE_CUSTOM_ID. Keep endpoint and parameters stable on retries.
        - **Delivery target.** target_trade_url is required. target_steam_id is optional;
          when present it must match the SteamID64 derived from the URL's partner value.

        Track the purchase through webhooks and GET /purchases. Provider acceptance is not
        Steam delivery. Completed delivery can later undergo a protection rollback.
        If the response is lost, retry the same request and look up purchases by custom_id.
      security:
        - apiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PurchaseRequest" }
            examples:
              syntheticPurchase:
                summary: Synthetic request; replace item and recipient before a real purchase.
                value: {"item_id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1","max_price": 20.0,"custom_id": "order-2026-05-05-0001","target_trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123"}
      responses:
        "201":
          description: Purchase created (or idempotent replay).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseCreated" }
        "400":
          description: Invalid request body (`MERCHANT_INVALID_*`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: Insufficient balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: "`MERCHANT_ITEM_NOT_FOUND`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            One of: `MERCHANT_DUPLICATE_CUSTOM_ID`, `MERCHANT_ITEM_UNAVAILABLE`,
            `MERCHANT_PRICE_CHANGED`, `MERCHANT_CONFLICT`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/merchant/buy-sync:
    post:
      operationId: createPurchaseSync
      tags: [Purchase]
      summary: Buy an exact item and wait for provider acknowledgement.
      description: |
        Same request, debit and matching-repeat checks as /buy. Waits up to about 30 seconds
        for provider acknowledgement; 200 does not mean Steam delivery is completed.
        202 identifies an existing in-flight purchase. A disconnect does not cancel a
        created purchase. Recover with the same custom_id and request, or list by custom_id.
        Repeats keep the existing purchase_id and can return current status and balance.
        A 409 failure does not prove a refund; inspect purchase detail and refund events.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PurchaseRequest" }
            examples:
              syntheticPurchase:
                summary: Synthetic request; replace item and recipient before a real purchase.
                value: {"item_id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1","max_price": 20.0,"custom_id": "order-2026-05-05-0001","target_trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123"}
      responses:
        "200":
          description: Provider acknowledged the purchase; delivery may still be in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseCreated" }
        "202":
          description: Purchase is in flight after the wait budget or waiting for provider funds. Track its status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseCreated" }
        "400":
          description: Invalid purchase request, including recipient/SteamID mismatch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: MERCHANT_INSUFFICIENT_BALANCE — insufficient balance for the actual price.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: MERCHANT_ITEM_NOT_FOUND.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: MERCHANT_DUPLICATE_CUSTOM_ID, MERCHANT_ITEM_UNAVAILABLE, MERCHANT_PRICE_CHANGED, MERCHANT_CONFLICT or MERCHANT_PURCHASE_FAILED.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/Error"
                  - $ref: "#/components/schemas/PurchaseFailedResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/merchant/buy-by-name:
    post:
      operationId: createPurchaseByName
      tags: [Purchase]
      summary: Buy the cheapest matching item with automatic attempt selection.
      description: |
        Selects by exact name, price ceiling and optional delivery-time ceiling. Tries up to
        five items within a shared 30-second budget. Each created attempt has its own
        purchase_id and shares the logical custom_id. Only the actual item price is debited.

        Persist custom_id before POST. After validation, repeats look up the latest attempt
        by custom_id alone, without comparing the other fields, and never start a second
        attempt cycle. The original cycle can still create a later attempt with a different
        purchase_id. A concurrent repeat reporting a failed attempt does not prove that the
        original cycle has finished. Keep the request unchanged and reconcile all purchases
        returned by GET /purchases?custom_id=... . Status/balance can reflect current values.

        200 acknowledges an accepted attempt, 202 reports an in-flight attempt, and a failed
        attempt read by a repeat returns a 409 error body. Initial exhaustion can also return
        409. Intermediate failures and 202 do not guarantee a purchase.created event.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PurchaseByNameRequest" }
            examples:
              syntheticPurchase:
                summary: Synthetic request; replace item and recipient before a real purchase.
                value: {"market_hash_name": "Sticker | MIBR | Budapest 2025","max_price": 0.05,"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123","custom_id": "order-2026-05-05-0001","estimated_time": 60}
      responses:
        "200":
          description: Provider accepted the returned attempt; Steam delivery can still be pending.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseCreated" }
        "202":
          description: Returned attempt is in flight. Track by purchase_id and reconcile the logical custom_id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseCreated" }
        "400":
          description: MERCHANT_INVALID_MARKET_HASH_NAME, MERCHANT_INVALID_MAX_PRICE, MERCHANT_INVALID_TRADE_URL, MERCHANT_INVALID_CUSTOM_ID, MERCHANT_INVALID_ESTIMATED_TIME or MERCHANT_NO_ITEMS_FOUND.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402":
          description: MERCHANT_INSUFFICIENT_BALANCE.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: MERCHANT_PURCHASE_FAILED on exhaustion/failed replay; MERCHANT_ITEM_UNAVAILABLE or MERCHANT_PRICE_CHANGED when a repeat finds the corresponding failed attempt. Refund is not implied.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/Error"
                  - $ref: "#/components/schemas/PurchaseFailedResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/merchant/purchases:
    get:
      operationId: listPurchases
      tags: [Purchase]
      summary: List purchases (cursor-paginated).
      security:
        - apiKey: []
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          schema:
            type: string
            enum: [created, processing, sent_to_steam, completed, failed]
            description: |
              Public purchase status filter. Five values total — `created` /
              `processing` / `sent_to_steam` (in-flight), `completed` / `failed`
              (delivery/attempt ended). A later protection rollback can change a completed
              purchase to failed. Unknown values return 400 MERCHANT_INVALID_STATUS.
        - in: query
          name: custom_id
          schema: { type: string }
        - in: query
          name: target_steam_id
          schema: { type: string }
        - in: query
          name: from
          schema: { type: string, format: date-time }
        - in: query
          name: to
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseList" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "400":
          description: MERCHANT_INVALID_STATUS — unsupported filter value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/merchant/purchases/{purchaseId}:
    get:
      operationId: getPurchase
      tags: [Purchase]
      summary: Inspect a single purchase.
      security:
        - apiKey: []
      parameters:
        - in: path
          name: purchaseId
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PurchaseDetail" }
        "404":
          description: "`MERCHANT_PURCHASE_NOT_FOUND`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  # --------------------- Webhook events ---------------------
  /api/v1/merchant/webhook-events:
    get:
      operationId: listWebhookEvents
      tags: [Webhook events]
      summary: Inspect outgoing webhook deliveries (read-only audit log).
      description: |
        Lists deliveries the system has queued or attempted on this merchant's behalf.
        Inspect stored events and response codes to reconcile delivery problems. This
        read-only endpoint does not create missing events or expose manual replay. Use
        the support replay procedure; replay preserves the event_id.
        Absence from this log is not proof that the underlying business state never changed.
      security:
        - apiKey: []
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, delivered, failed]
            description: |
              `sending` is a transient claim window (≤120s while a sender replica
              holds the row); it is not accepted as a filter value.
        - in: query
          name: order_id
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: OK.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEventList" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "400":
          description: MERCHANT_INVALID_STATUS — unsupported filter value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <api_key>`. Issued and rotated in the merchant cabinet
        at `merchant.tradeon.market`.

  responses:
    Unauthorized:
      description: MERCHANT_API_KEY_MISSING — missing/malformed Authorization header or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Authenticated but not allowed (e.g. merchant blocked).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: MERCHANT_RATE_LIMITED — per-merchant request budget exceeded. Wait at least Retry-After seconds.
      headers:
        Retry-After:
          description: Minimum delay in seconds before retrying.
          schema: { type: integer, minimum: 1 }
        X-RateLimit-Limit:
          description: Request limit for the applicable burst window.
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Remaining requests in the applicable burst window.
          schema: { type: integer, minimum: 0 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  parameters:
    WebhookSignature:
      in: header
      name: X-Signature
      required: true
      description: Lowercase hexadecimal HMAC-SHA256 of X-Timestamp + "." + raw request body, using your webhook secret.
      schema: { type: string, pattern: '^[a-f0-9]{64}$' }
    WebhookTimestamp:
      in: header
      name: X-Timestamp
      required: true
      description: Unix seconds for this delivery attempt. Verify within 300 seconds of your server clock.
      schema: { type: string, pattern: '^[0-9]+$' }
    WebhookEventId:
      in: header
      name: X-Event-Id
      required: true
      description: Matches body event_id; unchanged for retries and manual replay.
      schema: { type: string, format: uuid }
    WebhookEventType:
      in: header
      name: X-Event
      required: true
      description: Matches the body event type.
      schema: { type: string }

  headers:
    CatalogETag:
      description: Weak ETag for this snapshot; save and send the exact value including W/ and quotes.
      schema: { type: string, example: 'W/"snapshot-value"' }
    CatalogCursor:
      description: Optional replay checkpoint belonging to this snapshot. Present only together with X-Catalog-Epoch.
      schema: { type: string }
    CatalogEpoch:
      description: Optional engine epoch of X-Catalog-Cursor. Absence prevents a continuous dump-to-stream bootstrap.
      schema: { type: string }

  schemas:
    DepositWalletRequest:
      type: object
      required: [network]
      properties:
        network: { type: string, enum: [tron, bsc] }
    DepositWalletList:
      type: object
      required: [issuance_enabled, can_create, wallets]
      properties:
        issuance_enabled: { type: boolean, description: Provider routing for new merchant wallets. }
        can_create: { type: boolean, description: New issuance also requires writes and verified settlement to be enabled. }
        wallets: { type: array, items: { $ref: "#/components/schemas/DepositWallet" } }
    DepositWallet:
      type: object
      required: [id, provider, currency, network, status, address, memo, can_deposit, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        provider: { type: string, enum: [whitepay] }
        currency: { type: string, enum: [USDT] }
        network: { type: string, enum: [tron, bsc] }
        status: { type: string, enum: [prepared, creation_started, requires_reconciliation, bound] }
        address: { type: [string, "null"], description: Present only after a validated binding. }
        memo: { type: [string, "null"], description: Include separately when nonempty; QR contains only the address. }
        can_deposit:
          type: boolean
          description: Last saved provider status is ACTIVE and verified settlement is enabled. Reconcile to refresh status; false means do not send funds.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: "aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb"
        provider: whitepay
        currency: USDT
        network: tron
        status: requires_reconciliation
        address: null
        memo: null
        can_deposit: false
        created_at: "2026-09-22T20:00:00Z"
        updated_at: "2026-09-22T20:00:01Z"

    DepositList:
      type: object
      required: [deposits, next_cursor]
      properties:
        deposits: { type: array, items: { $ref: "#/components/schemas/DepositItem" } }
        next_cursor: { type: [string, "null"] }

    DepositItem:
      type: object
      required: [id, provider, amount, currency, status, created_at]
      properties:
        id: { type: string, format: uuid }
        provider: { type: string, enum: [heleket, whitepay] }
        amount: { type: number, description: Credited USD after the merchant tier commission. }
        currency: { type: string, enum: [USD] }
        network: { type: [string, "null"], description: tron for TRC20 or bsc for BEP20. }
        destination: { type: [string, "null"] }
        tx_id: { type: [string, "null"] }
        status: { type: string, enum: [paid] }
        created_at: { type: string, format: date-time }

    DepositFees:
      type: object
      required: [currency, tiers]
      properties:
        currency: { type: string, enum: [USD] }
        tiers:
          type: array
          items:
            type: object
            required: [min_amount, max_amount, percent]
            properties:
              min_amount: { type: number, description: Inclusive USD lower bound. }
              max_amount: { type: [number, "null"], description: Exclusive USD upper bound; null means no upper bound. }
              percent: { type: number, description: Commission percentage; 1 means 1 percent. }

    PurchaseByNameRequest:
      examples:
        - {"market_hash_name": "Sticker | MIBR | Budapest 2025","max_price": 0.05,"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123","custom_id": "order-2026-05-05-0001","estimated_time": 60}
      type: object
      required: [market_hash_name, max_price, trade_url, custom_id]
      properties:
        market_hash_name: { type: string, minLength: 1, maxLength: 200, description: Exact canonical Steam name. }
        max_price: { type: number, exclusiveMinimum: 0, description: USD consent ceiling; only actual price is debited. }
        trade_url: { type: string, description: Required recipient Steam trade URL; SteamID64 is derived from partner. }
        custom_id: { type: string, minLength: 1, maxLength: 100, description: Persisted logical operation key shared by its purchase attempts. }
        estimated_time: { type: [integer, "null"], minimum: 10, description: Delivery ceiling in minutes; excludes unknown estimates when set. }

    Error:
      examples:
        - {"error": {"code": "MERCHANT_API_KEY_MISSING","message": "Authorization: Bearer mer_live_<key> header required"}}
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable, machine-readable error code (e.g. `MERCHANT_PRICE_CHANGED`).
            message:
              type: string

    Balance:
      examples:
        - {"available": 481.6,"on_hold": 18.4,"total_deposited": 1000.0,"total_spent": 518.4,"currency": "USD","deposit_addresses": {"trc20": "TXY...USDT_TRC20_ADDRESS","bep20": "0x...USDT_BEP20_ADDRESS"}}
      type: object
      required: [available, on_hold, total_deposited, total_spent, currency, deposit_addresses]
      properties:
        available:
          type: number
          format: decimal
          description: Free balance — what you can spend right now.
        on_hold:
          type: number
          format: decimal
          description: |
            Sum of in-flight purchase orders — every purchase whose public status
            is `created`, `processing`, or `sent_to_steam`. These amounts are already
            debited. Completion reduces on_hold without crediting available. Only an
            applied refund credits available; failed status alone does not prove a refund.
        total_deposited:
          type: number
          format: decimal
          description: Lifetime sum of confirmed deposits (`Deposit` ledger rows).
        total_spent:
          type: number
          format: decimal
          description: |
            Lifetime net spend = sum of `Purchase` debits minus sum of `Refund` credits.
        currency:
          type: string
          enum: [USD]
        deposit_addresses:
          type: object
          description: Static USDT addresses for top-ups. Object is always present; each address is null until provisioned.
          required: [trc20, bep20]
          properties:
            trc20: { type: [string, "null"] }
            bep20: { type: [string, "null"] }

    CatalogItem:
      examples:
        - {"id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1","market_hash_name": "AK-47 | Redline (Field-Tested)","image_url": "https://cdn.tradeon.market/items/ak47-redline-ft.png","price": 18.4,"exterior": "Field-Tested","exterior_code": "FT","float": 0.2451,"paint_seed": 462,"paint_index": 316,"inspect_url": "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20...","asset_id": "38947116503","steam_id": null,"class_id": "9385506221","instance_id": "188530139","is_stat_trak": false,"is_souvenir": false,"has_stickers": true,"stickers_count": 2,"stickers": [{"slot": 0,"name": "Sticker | Vox Eminor | Katowice 2014","sticker_id": "1234","image_url": "https://cdn.tradeon.market/stickers/vox-eminor-kato14.png","wear": 0.12},{"slot": 2,"name": "Sticker | Titan | Katowice 2014","sticker_id": null,"image_url": "https://cdn.tradeon.market/stickers/titan-kato14.png","wear": null}],"category": "rifle","weapon_type": "AK-47","rarity": "classified","rarity_color": "#d32ce6","delivery_type": "instant","estimated_delivery_minutes": 2}
      type: object
      description: Nullable metadata can be null in list/search and omitted from a dump. The stickers array is always present.
      required: [id, market_hash_name, price, delivery_type, is_stat_trak, is_souvenir, has_stickers, stickers_count, stickers]
      properties:
        id: { type: string, format: uuid }
        market_hash_name: { type: string }
        image_url: { type: [string, "null"] }
        price: { type: number, format: decimal }
        exterior: { type: [string, "null"] }
        exterior_code: { type: [string, "null"] }
        float: { type: [number, "null"], format: decimal }
        paint_seed: { type: [integer, "null"] }
        paint_index: { type: [integer, "null"] }
        inspect_url:
          type: [string, "null"]
          description: Supplier-stock inspect link; can change between snapshots.
        asset_id:
          type: [string, "null"]
          description: Supplier-stock Steam asset ID. Volatile across trades; keep as a string.
        steam_id:
          type: [string, "null"]
          description: SteamID64 of the supplier-stock holder, not the recipient. Keep as a string.
        class_id: { type: [string, "null"] }
        instance_id: { type: [string, "null"] }
        stickers:
          type: array
          description: Empty when absent or detail is unavailable, even when has_stickers is true.
          items: { $ref: "#/components/schemas/Sticker" }
        is_stat_trak: { type: boolean }
        is_souvenir: { type: boolean }
        has_stickers: { type: boolean }
        stickers_count: { type: integer }
        category: { type: [string, "null"] }
        weapon_type: { type: [string, "null"] }
        rarity: { type: [string, "null"] }
        rarity_color: { type: [string, "null"] }
        delivery_type:
          type: string
          enum: [instant, manual]
          description: |
            How fast TradeOn can dispatch this listing. Indicative — bot scheduling and
            CS2 trade-cooldowns can still extend the actual delivery window.
        estimated_delivery_minutes:
          type: [integer, "null"]

    Sticker:
      type: object
      required: [slot, name]
      properties:
        slot: { type: integer, description: Zero-based slot position. }
        name: { type: string }
        sticker_id: { type: [string, "null"] }
        image_url: { type: [string, "null"] }
        wear: { type: [number, "null"], description: Sticker wear from 0 to 1 when available. }

    CatalogList:
      examples:
        - {"items": [{"id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1","market_hash_name": "AK-47 | Redline (Field-Tested)","image_url": "https://cdn.tradeon.market/items/ak47-redline-ft.png","price": 18.4,"exterior": "Field-Tested","exterior_code": "FT","float": 0.2451,"paint_seed": 462,"paint_index": 316,"inspect_url": "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20...","asset_id": "38947116503","steam_id": null,"class_id": "9385506221","instance_id": "188530139","is_stat_trak": false,"is_souvenir": false,"has_stickers": true,"stickers_count": 2,"stickers": [{"slot": 0,"name": "Sticker | Vox Eminor | Katowice 2014","sticker_id": "1234","image_url": "https://cdn.tradeon.market/stickers/vox-eminor-kato14.png","wear": 0.12},{"slot": 2,"name": "Sticker | Titan | Katowice 2014","sticker_id": null,"image_url": "https://cdn.tradeon.market/stickers/titan-kato14.png","wear": null}],"category": "rifle","weapon_type": "AK-47","rarity": "classified","rarity_color": "#d32ce6","delivery_type": "instant","estimated_delivery_minutes": 2}],"next_cursor": "eyJQcmljZSI6MTguNCwiSWQiOiI4ZTQyYmI5Yy0yZTExLTRkNDAtOWQ1MS02ZTVkMmQ0YjJjZjEifQ=="}
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: "#/components/schemas/CatalogItem" }
        next_cursor:
          type: [string, "null"]
          description: Pass back as `cursor` to fetch the next page. `null` at end.

    CatalogSearch:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/CatalogItem" }

    CatalogFullDump:
      type: object
      required: [generated_at, items_count, items]
      properties:
        generated_at: { type: string, format: date-time }
        items_count: { type: integer }
        items:
          type: array
          items: { $ref: "#/components/schemas/CatalogItem" }

    PurchaseRequest:
      examples:
        - {"item_id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1","max_price": 20.0,"custom_id": "order-2026-05-05-0001","target_trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123"}
      type: object
      required: [item_id, max_price, custom_id, target_trade_url]
      properties:
        item_id:
          type: string
          format: uuid
          description: From the `id` field of a `CatalogItem`.
        max_price:
          type: number
          format: decimal
          description: |
            USD consent ceiling, greater than zero. The actual item price is debited
            at creation and must not exceed this value; max_price is not reserved.
          exclusiveMinimum: 0
        target_steam_id:
          type: [string, "null"]
          description: Optional SteamID64 string. Must match the recipient derived from target_trade_url; otherwise 400 MERCHANT_STEAM_ID_MISMATCH.
        target_trade_url:
          type: string
          description: |
            Required Steam trade URL of the recipient. The SteamID64 is derived from
            partner. A SteamID64 alone cannot be used for delivery.
        custom_id:
          type: string
          maxLength: 100
          description: |
            Persist before POST. Matching repeats return the existing purchase with
            current status/balance; the supplied max_price must cover its saved price.
            Different item or recipient parameters return 409 MERCHANT_DUPLICATE_CUSTOM_ID.
          minLength: 1

    PurchaseItemSummary:
      type: object
      required: [market_hash_name, price, delivery_type, estimated_delivery_minutes]
      properties:
        market_hash_name: { type: string }
        price: { type: number, format: decimal }
        delivery_type:
          type: string
          enum: [instant, manual]
          description: Snapshot of the catalogue value at purchase time.
        estimated_delivery_minutes:
          type: [integer, "null"]
          description: Snapshot of the catalogue value at purchase time. Indicative.

    PurchaseItemDetail:
      type: object
      description: Purchase item snapshot; its price is on PurchaseDetail.price, not inside this object.
      required: [id, market_hash_name, image_url, exterior, float, delivery_type, estimated_delivery_minutes]
      properties:
        id: { type: [string, "null"], format: uuid }
        market_hash_name: { type: string }
        image_url: { type: [string, "null"] }
        exterior: { type: [string, "null"] }
        float: { type: [number, "null"], format: decimal }
        delivery_type: { type: string, enum: [instant, manual] }
        estimated_delivery_minutes: { type: [integer, "null"] }

    PurchaseFailureCode:
      type: string
      description: Normalized purchase reason, independent of HTTP error codes and refund policy. Unknown legacy reasons project to UNKNOWN.
      enum: [INVENTORY_PRIVATE, TRADING_UNAVAILABLE, INVALID_TRADE_URL, TRADE_DECLINED, TRADE_COUNTERED, TRADE_CANCELLED, TRADE_EXPIRED, SELLER_CANCELLED, ITEM_UNAVAILABLE, PRICE_CHANGED, PROVIDER_ERROR, MANUAL_REVIEW, REVERTED_BY_BUYER, REVERTED_BY_SELLER, REVERT_UNKNOWN, UNKNOWN]

    PurchaseFailedResponse:
      type: object
      required: [error, purchase_id, custom_id, failure, trade_offer_id, trade_offer_url]
      properties:
        error: { $ref: "#/components/schemas/Error/properties/error" }
        purchase_id: { type: string, format: uuid }
        custom_id: { type: [string, "null"] }
        failure: { $ref: "#/components/schemas/PurchaseFailure" }
        trade_offer_id: { type: [string, "null"], description: Last known Steam offer ID; null does not prove no offer was sent. }
        trade_offer_url: { type: [string, "null"] }

    PurchaseFailure:
      type: object
      required: [code]
      properties:
        code:
          $ref: "#/components/schemas/PurchaseFailureCode"
        message:
          type: [string, "null"]
        refunded_amount:
          type: [number, "null"]
          format: decimal

    PurchaseStatus:
      type: string
      description: |
        Public purchase status. Five values total — `created` / `processing` /
        `sent_to_steam` (in-flight), `completed` / `failed` (terminal).
        Completed means delivered, but a late protection rollback can change it to failed.
        Internal order states (e.g. cancelled, refunded, failed-by-buyer) are
        all collapsed into `failed`; inspect `failure.code` and
        `failure.refunded_amount` on the purchase detail to distinguish them.
      enum:
        - created
        - processing
        - sent_to_steam
        - completed
        - failed

    PurchaseCreated:
      examples:
        - {"purchase_id": "1a2b3c4d-5e6f-7080-91a2-b3c4d5e6f708","custom_id": "order-2026-05-05-0001","status": "created","item": {"market_hash_name": "AK-47 | Redline (Field-Tested)","price": 18.4,"delivery_type": "instant","estimated_delivery_minutes": 2},"balance_after": 481.6,"created_at": "2026-05-05T16:00:00.000Z"}
      type: object
      required: [purchase_id, custom_id, status, item, balance_after, created_at]
      properties:
        purchase_id: { type: string, format: uuid }
        custom_id: { type: string }
        status: { $ref: "#/components/schemas/PurchaseStatus" }
        item: { $ref: "#/components/schemas/PurchaseItemSummary" }
        balance_after:
          type: number
          format: decimal
          description: Balance after creation, or current balance when re-reading an existing purchase.
        created_at: { type: string, format: date-time }

    PurchaseDetail:
      examples:
        - {"purchase_id": "1a2b3c4d-5e6f-7080-91a2-b3c4d5e6f708","custom_id": "order-2026-05-05-0001","status": "completed","item": {"id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1","market_hash_name": "AK-47 | Redline (Field-Tested)","image_url": "https://cdn.tradeon.market/items/ak47-redline-ft.png","exterior": "Field-Tested","float": 0.2451,"delivery_type": "instant","estimated_delivery_minutes": 2},"price": 18.4,"target_steam_id": "76561197960278073","trade_offer_id": "9876543210","trade_offer_url": "https://steamcommunity.com/tradeoffer/9876543210","created_at": "2026-05-05T16:00:00.000Z","completed_at": "2026-05-05T16:02:13.421Z","failure": null}
      type: object
      required: [purchase_id, custom_id, status, item, price, target_steam_id, trade_offer_id, trade_offer_url, created_at, completed_at, failure]
      properties:
        purchase_id: { type: string, format: uuid }
        custom_id: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/PurchaseStatus" }
        item: { $ref: "#/components/schemas/PurchaseItemDetail" }
        price: { type: number, format: decimal }
        target_steam_id: { type: [string, "null"] }
        trade_offer_id: { type: [string, "null"] }
        trade_offer_url: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        failure:
          oneOf:
            - $ref: "#/components/schemas/PurchaseFailure"
            - type: "null"

    PurchaseList:
      type: object
      required: [purchases, next_cursor]
      properties:
        purchases:
          type: array
          items: { $ref: "#/components/schemas/PurchaseDetail" }
        next_cursor: { type: [string, "null"] }

    WebhookEventListItem:
      type: object
      required: [id, event_type, delivery_status, delivery_attempts, created_at]
      properties:
        id: { type: string, format: uuid }
        event_type:
          type: string
          enum:
            - purchase.created
            - purchase.sent_to_steam
            - purchase.completed
            - purchase.failed
            - purchase.refunded
            - purchase.reverted_by_buyer
            - purchase.reverted_by_seller
            - balance.deposited
        order_id: { type: [string, "null"], format: uuid }
        delivery_status:
          type: string
          enum: [pending, sending, delivered, failed]
          description: |
            `pending` (queued, awaiting next attempt) / `delivered` / `failed` are
            the durable states. `sending` may surface transiently while a sender
            replica holds the claim (≤120s) and is not accepted by the `?status=`
            query filter.
        delivery_attempts: { type: integer }
        last_response_code: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }

    WebhookEventList:
      type: object
      required: [events]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventListItem" }
        next_cursor: { type: [string, "null"] }

    # --- Webhook envelopes ---
    WebhookEnvelopeBase:
      type: object
      required: [event, event_id, occurred_at, data]
      properties:
        event:
          type: string
          description: Event type (e.g. `purchase.created`).
        event_id:
          type: string
          format: uuid
          description: |
            Stored event ID, unchanged across delivery retries and manual replay. After
            signature/timestamp verification, acknowledge an already durably accepted
            event with 2xx without repeating business effects. Also dedup business effects
            by the relevant operation key (for deposits, (data.provider, data.provider_uuid)).
        occurred_at:
          type: string
          format: date-time
          description: Payload preparation time; not a monotonic state version or guaranteed delivery order.
        data:
          type: object
          description: Event-specific payload. Unavailable optional values are omitted rather than serialized as null.

    WebhookEnvelopePurchaseCreated:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.created" }
            data:
              type: object
              required: [purchase_id, custom_id, item, price]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                item:
                  type: object
                  required: [market_hash_name]
                  properties:
                    market_hash_name: { type: string }
                    image_url: { type: string }
                price: { type: number, format: decimal }
                target_steam_id: { type: string }

    WebhookEnvelopePurchaseSentToSteam:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.sent_to_steam" }
            data:
              type: object
              required: [purchase_id, custom_id]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                trade_offer_id: { type: string }
                trade_offer_url: { type: string }

    WebhookEnvelopePurchaseCompleted:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.completed" }
            data:
              type: object
              required: [purchase_id, custom_id]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                completed_at: { type: string, format: date-time }

    WebhookEnvelopePurchaseFailed:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.failed" }
            data:
              type: object
              required: [purchase_id, custom_id, failure_code]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                failure_code:
                  type: string
                  description: |
                    New events use PurchaseFailureCode values. Stored historical events may
                    retain provider codes; consumers must tolerate unknown identifiers.
                failure_message: { type: string }
                trade_offer_id: { type: string, description: Last known Steam offer ID; omitted if unknown. }
                trade_offer_url: { type: string }
                refunded_amount:
                  type: number
                  format: decimal
                  description: |
                    Present when this failure includes an applied refund. Do not expect
                    a second purchase.refunded event for that same automatic refund.

    WebhookEnvelopePurchaseRefunded:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.refunded" }
            data:
              type: object
              required: [purchase_id, custom_id, refunded_amount, balance_after]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                refunded_amount: { type: number, format: decimal }
                balance_after: { type: number, format: decimal }
                reason: { type: string }

    WebhookEnvelopePurchaseRevertedByBuyer:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.reverted_by_buyer" }
            data:
              type: object
              required: [purchase_id, custom_id, failure_code, refunded_amount]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                failure_code: { type: string }
                failure_message: { type: string }
                trade_offer_id: { type: string, description: Last known Steam offer ID; omitted if unknown. }
                trade_offer_url: { type: string }
                refunded_amount: { type: number, const: 0, description: Buyer rollback does not refund the merchant. }

    WebhookEnvelopePurchaseRevertedBySeller:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "purchase.reverted_by_seller" }
            data:
              type: object
              required: [purchase_id, custom_id, failure_code, refunded_amount]
              properties:
                purchase_id: { type: string, format: uuid }
                custom_id: { type: string }
                failure_code: { type: string }
                failure_message: { type: string }
                trade_offer_id: { type: string, description: Last known Steam offer ID; omitted if unknown. }
                trade_offer_url: { type: string }
                refunded_amount: { type: number, description: Full purchase price returned to the merchant in USD. }

    WebhookEnvelopeBalanceDeposited:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelopeBase"
        - type: object
          properties:
            event: { const: "balance.deposited" }
            data:
              type: object
              required: [amount, currency, balance_after, network, provider, provider_uuid]
              properties:
                amount: { type: number, format: decimal }
                currency:
                  type: string
                  enum: [USD]
                balance_after: { type: number, format: decimal }
                network:
                  type: string
                  enum: [tron, bsc]
                  description: |
                    USDT network — `tron` for USDT-TRC20, `bsc` for USDT-BEP20
                    (matches the deposit address on `GET /merchant/balance`).
                txid:
                  type: string
                  description: |
                    On-chain transaction ID. Field is omitted from the JSON
                    payload when not yet available.
                provider:
                  type: string
                  enum: [heleket, whitepay]
                provider_uuid:
                  type: string
                  description: |
                    Stable deposit identifier within its named provider. Different event IDs can
                    describe the same (provider, provider_uuid); credit that deposit once. Investigate
                    conflicting amounts instead of applying an additional credit.

# ----------------------- Webhooks -----------------------
webhooks:
  purchase.created:
    post:
      operationId: receivePurchaseCreated
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: A purchase attempt was created.
      description: |
        Event envelope and signature scheme are identical for every event. Verify
        X-Signature as lowercase hexadecimal HMAC-SHA256 of X-Timestamp + "." + raw body,
        using your webhook secret, and check timestamp freshness. See
        https://merchant.tradeon.market/docs/webhooks/signature for executable verification.
        By-name intermediate failures and a 202 response do not guarantee this event.
        Retries concern stored events; automatic catch-up after event-creation failure
        is not guaranteed. Reconcile purchase state separately.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelopePurchaseCreated"
      responses:
        "2XX":
          description: Durably accepted, including an authenticated duplicate already accepted earlier; do not repeat business effects.
        "4XX":
          description: Terminal for ordinary 4xx. Exceptions 408 and 429 are transient and use the retry schedule.
        "5XX":
          description: Transient; automatic delays are 1 minute, 10 minutes and 1 hour, then failed after the fourth total attempt. A failed manual replay does not start another automatic chain.

  purchase.sent_to_steam:
    post:
      operationId: receivePurchaseSentToSteam
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: A trade offer was dispatched on Steam; offer identifiers may be unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelopePurchaseSentToSteam"
      responses:
        "2XX": { description: Acknowledged. }

  purchase.completed:
    post:
      operationId: receivePurchaseCompleted
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: The item was delivered to the buyer's Steam account.
      description: A later Steam Trade Protection rollback can still produce a reversal or failure event. Continue handling updates after completion.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelopePurchaseCompleted"
      responses:
        "2XX": { description: Acknowledged. }

  purchase.failed:
    post:
      operationId: receivePurchaseFailed
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: A purchase attempt failed, including an unknown-cause late rollback.
      description: refunded_amount is present when the failure includes an applied refund; a failed status alone does not prove a refund.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelopePurchaseFailed"
      responses:
        "2XX": { description: Acknowledged. }

  purchase.refunded:
    post:
      operationId: receivePurchaseRefunded
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: A later refund credited the merchant balance.
      description: May follow a previously reported failure. Delivery order and receipt of the earlier failure are not guaranteed; do not require it before applying this event.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelopePurchaseRefunded"
      responses:
        "2XX": { description: Acknowledged. }

  balance.deposited:
    post:
      operationId: receiveBalanceDeposited
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: A USDT deposit was confirmed by the upstream payment provider.
      description: |
        Dedup deliveries on event_id and the deposit business effect on (data.provider, data.provider_uuid).
        Different event IDs can identify the same deposit. Credit it only once; investigate
        conflicting payloads without applying a second credit. Acknowledge a verified,
        already accepted duplicate with 2xx. balance_after is a snapshot, not a state version.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookEnvelopeBalanceDeposited"
      responses:
        "2XX": { description: Acknowledged. }

  purchase.reverted_by_buyer:
    post:
      operationId: receivePurchaseRevertedByBuyer
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: The buyer rolled back a delivered trade under Steam Trade Protection.
      description: Can arrive after purchase.completed. refunded_amount is zero; no merchant refund is due for this rollback.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelopePurchaseRevertedByBuyer" }
      responses:
        "2XX": { description: "Durably accepted, or a verified duplicate already accepted earlier." }

  purchase.reverted_by_seller:
    post:
      operationId: receivePurchaseRevertedBySeller
      security: []
      parameters:
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookTimestamp" }
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEventType" }
      summary: The seller rolled back a delivered trade under Steam Trade Protection.
      description: Can arrive after purchase.completed. refunded_amount is the full purchase price credited to the merchant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEnvelopePurchaseRevertedBySeller" }
      responses:
        "2XX": { description: "Durably accepted, or a verified duplicate already accepted earlier." }
