openapi: 3.1.0
info:
  title: Reseller API
  version: "1.0.0"
  summary: Server-to-server order placement on this store, billed to a reseller wallet.
  description: |
    A small signed REST interface. You send form-encoded or JSON parameters over
    HTTPS; the store authenticates the request, prices the order with your
    reseller discount, charges your wallet, and starts delivery — all in one step.

    There are no sessions, cookies or tokens. Every request — including
    read-only ones — carries three parameters: `key`, `time` and `sign`.

    ## Signature

    1. Take every request parameter **except** `sign`.
    2. Sort the parameter names alphabetically (ASCII).
    3. Join them as `name=value&` pairs, with a trailing `&` after every pair.
    4. `sign = MD5( MD5( joined + secret ) )` — a double MD5, lowercase hex.

    The `secret` is appended immediately after the trailing `&` of the last pair.
    Use the exact strings you are sending: if you sign `time=1717000000` you must
    send that same value. The `sign` parameter is never part of the signed string.

    Requests whose `time` differs from the server clock by more than **300
    seconds** are rejected.

    ## How the request is sent

    Read endpoints (`GET`) take the signed parameters in the query string.
    `POST /api/reseller/order` accepts either `application/x-www-form-urlencoded`
    or `application/json`; the body parameters are merged with the query string
    before the signature is verified.

    ## Money and idempotency

    The order is charged to the wallet of the customer account the key belongs
    to. `ref` is your own reference for the order and doubles as the idempotency
    key: replaying a request with the same `ref` never charges twice — it returns
    the original order with `replayed: true`. Spend caps are checked inside the
    same locked transaction as the debit, so a burst cannot slip past a limit.

    ## Rate limits

    Order attempts are limited to **120 per key per 10-minute window** (and 3×
    that per source address). Exceeding it returns `429` with a `Retry-After`
    header.

    ## Errors

    Failures return JSON `{ "error": "message" }` with one of these statuses:
    `400` bad input, `401` missing/invalid credential or expired `time`,
    `402` insufficient wallet balance, `403` key disabled/expired or a limit
    exceeded, `404` unknown package or order, `429` rate limited, `500` server
    error.
  contact:
    name: Store support
    url: /docs
  license:
    name: Proprietary
servers:
  - url: /
    description: This store
tags:
  - name: Catalogue
    description: What this key is allowed to order, at this key's prices.
  - name: Wallet
    description: The balance every order is charged from.
  - name: Orders
    description: Place an order and read its status.
security:
  - ResellerSignature: []
paths:
  /api/reseller/products:
    get:
      tags: [Catalogue]
      operationId: listProducts
      summary: List orderable products and packages
      description: |
        Returns every product this key may order together with its active
        packages, priced at this key's discount. Products that cannot be ordered
        through the API are excluded rather than failing later: `url` and
        `productgroup` products (no packages), locked "special" products (won,
        not bought), stock-out products, and — when the key is restricted with
        `allowed_products` — every product outside that set.

        `price` is what this key pays after the discount; `base_price` is the
        undiscounted store price, so a saving can be displayed.
      parameters:
        - $ref: "#/components/parameters/key"
        - $ref: "#/components/parameters/time"
        - $ref: "#/components/parameters/sign"
      responses:
        "200":
          description: The orderable catalogue for this key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/reseller/balance:
    get:
      tags: [Wallet]
      operationId: getBalance
      summary: Wallet balance and key limits
      description: |
        The balance of the account this key belongs to — the balance every order
        is charged from — plus the key's own discount and caps, so a reseller can
        tell when to top up and what it may spend.
      parameters:
        - $ref: "#/components/parameters/key"
        - $ref: "#/components/parameters/time"
        - $ref: "#/components/parameters/sign"
      responses:
        "200":
          description: Wallet balance, account and key limits.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BalanceResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/reseller/order:
    post:
      tags: [Orders]
      operationId: placeOrder
      summary: Place an order
      description: |
        Prices the order with this key's discount, charges the wallet, stores the
        order and starts delivery in one step.

        `ref` is required and is the idempotency key. A request replayed with the
        same `ref` (or one that raced with an identical request) returns the
        original order and does not charge again — see the `replayed` field.

        Caps are checked before the debit, in this order: per-order amount, daily
        spend, monthly spend, daily order count, then the wallet balance. The
        first one that would be exceeded fails the request and nothing is charged.
      parameters:
        - $ref: "#/components/parameters/key"
        - $ref: "#/components/parameters/time"
        - $ref: "#/components/parameters/sign"
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/PlaceOrderRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/PlaceOrderRequest"
      responses:
        "200":
          description: |
            The order was accepted (or is a replay of an earlier one). Check
            `replayed` to tell the two apart.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PlaceOrderResponse"
                  - $ref: "#/components/schemas/ReplayResponse"
        "400":
          description: |
            Missing or invalid input: `product_id`/`package_code` missing, a bad
            `ref` (must be 6–64 chars of `A–Z a–z 0–9 . _ : -`), or an invalid
            `player_id`/`uid` (4–64 chars).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Insufficient wallet balance for this order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            Key disabled or expired, the product is outside the key's
            `allowed_products`, or a per-order / daily / monthly cap would be
            exceeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: The package (or the product) was not found or is not orderable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Read an order's status
      description: |
        Look up one order by the `ref` you placed it with, or by its
        `order_code`. Orders are scoped to the key that placed them.
      parameters:
        - $ref: "#/components/parameters/key"
        - $ref: "#/components/parameters/time"
        - $ref: "#/components/parameters/sign"
        - name: ref
          in: query
          required: false
          description: The `ref` used to place the order. Send this or `order_code`.
          schema:
            type: string
        - name: order_code
          in: query
          required: false
          description: The store's order code. Send this or `ref`.
          schema:
            type: string
        - name: id
          in: query
          required: false
          description: Alias of `order_code`.
          schema:
            type: string
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderStatusResponse"
        "400":
          description: Neither `ref` nor `order_code` was sent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No order with that `ref` / `order_code` belongs to this key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          $ref: "#/components/responses/ServerError"

components:
  securitySchemes:
    ResellerSignature:
      type: apiKey
      in: query
      name: key
      description: |
        Not a bearer token. Every request must carry three signed parameters —
        `key` (the public key id, e.g. `RSL-XXXXXXXXXXXX`), `time` (current Unix
        time in seconds) and `sign` (a double-MD5 signature over all parameters
        with the key's secret). See the API description for the exact algorithm.
        On `POST` the parameters may instead be sent in a form-encoded or JSON
        body.

  parameters:
    key:
      name: key
      in: query
      required: true
      description: Public API key id, e.g. `RSL-XXXXXXXXXXXX`.
      schema:
        type: string
    time:
      name: time
      in: query
      required: true
      description: Current Unix time in seconds. Must be within 300 seconds of the server clock.
      schema:
        type: integer
    sign:
      name: sign
      in: query
      required: true
      description: Request signature — `MD5(MD5(sorted name=value& pairs + secret))`, lowercase hex.
      schema:
        type: string

  responses:
    BadRequest:
      description: A required parameter was missing or malformed.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: Missing credential, unknown key, invalid signature, or expired `time`.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: The key is disabled or expired, or is not allowed this resource.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: The requested resource does not exist.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: Too many requests. See the `Retry-After` header.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ServerError:
      description: Something went wrong on the server. The request can be retried.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable reason. Do not match on the text — match on the HTTP status.

    PlaceOrderRequest:
      type: object
      required: [product_id, package_code, player_id, ref]
      properties:
        product_id:
          type: string
          description: Product id from `GET /api/reseller/products`.
        package_code:
          type: string
          description: Package `code` from `GET /api/reseller/products`.
        player_id:
          type: string
          description: The in-game id to deliver to. 4–64 characters. `uid` is accepted as an alias.
        uid:
          type: string
          description: Alias of `player_id`.
        nickname:
          type: string
          description: Optional in-game name, recorded with the order (max 120 chars).
        quantity:
          type: integer
          minimum: 1
          maximum: 10
          default: 1
          description: Whole number, clamped to 1–10. A fractional value is truncated, never rounded.
        ref:
          type: string
          pattern: "^[A-Za-z0-9_.:-]{6,64}$"
          description: |
            Your own reference for this order. Required. Doubles as the
            idempotency key: a repeat with the same `ref` returns the original
            order and never charges twice.

    PlaceOrderResponse:
      type: object
      required: [ok, currency, order, wallet]
      properties:
        ok:
          type: boolean
          const: true
        currency:
          type: string
          description: The store's currency symbol.
        order:
          type: object
          required: [id, ref, product_id, package_code, quantity, amount, status]
          properties:
            id:
              type: string
              description: The store's order code.
            ref:
              type: string
            product_id:
              type: string
            package_code:
              type: string
            quantity:
              type: integer
            amount:
              type: number
              description: Total charged to the wallet, discount included.
            status:
              type: string
              const: Processing
        wallet:
          type: object
          required: [balance]
          properties:
            balance:
              type: number
              description: Wallet balance after this order was charged.

    ReplayResponse:
      type: object
      required: [ok, replayed, currency, order]
      properties:
        ok:
          type: boolean
          const: true
        replayed:
          type: boolean
          const: true
          description: This request reused a `ref` that was already charged — nothing was charged again.
        currency:
          type: string
        order:
          type: object
          required: [id, amount, status, quantity]
          properties:
            id:
              type: string
              description: The order code the first request created.
            amount:
              type: number
              description: The amount charged by the original order.
            status:
              type: string
            quantity:
              type: integer

    ProductsResponse:
      type: object
      required: [ok, currency, discount_percent, products]
      properties:
        ok:
          type: boolean
          const: true
        currency:
          type: string
        discount_percent:
          type: number
          description: This key's discount, applied to every `price` below.
        products:
          type: array
          items:
            $ref: "#/components/schemas/Product"

    Product:
      type: object
      required: [id, name, packages]
      properties:
        id:
          type: string
          description: Use as `product_id` when placing an order.
        name:
          type: string
        image:
          type: [string, "null"]
        description:
          type: [string, "null"]
        packages:
          type: array
          items:
            $ref: "#/components/schemas/Package"

    Package:
      type: object
      required: [code, label, price, base_price, in_stock]
      properties:
        code:
          type: string
          description: Use as `package_code` when placing an order.
        label:
          type: string
        price:
          type: number
          description: What this key pays, after the discount.
        base_price:
          type: number
          description: The undiscounted store price.
        in_stock:
          type: boolean

    BalanceResponse:
      type: object
      required: [ok, currency, wallet, account, key]
      properties:
        ok:
          type: boolean
          const: true
        currency:
          type: string
        wallet:
          type: object
          required: [balance, total_spent]
          properties:
            balance:
              type: number
              description: The balance orders are charged from.
            total_spent:
              type: number
        account:
          type: object
          required: [id, name, email]
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
        key:
          type: object
          required: [id, label, discount_percent]
          properties:
            id:
              type: string
              description: The public key id (`key`).
            label:
              type: string
            discount_percent:
              type: number
            max_order_amount:
              type: [number, "null"]
              description: Per-order spend cap, or null when unset.
            daily_max_amount:
              type: [number, "null"]
              description: Daily spend cap, or null when unset.
            monthly_max_amount:
              type: [number, "null"]
              description: Monthly spend cap, or null when unset.
            daily_order_limit:
              type: [integer, "null"]
              description: Daily order-count cap, or null when unset.
            allowed_products:
              type: [array, "null"]
              items:
                type: string
              description: Product ids this key may order, or null for every product.
            expires_at:
              type: [string, "null"]
              description: ISO timestamp after which the key stops working, or null for no expiry.

    OrderStatusResponse:
      type: object
      required: [ok, currency, order]
      properties:
        ok:
          type: boolean
          const: true
        currency:
          type: string
        order:
          type: object
          properties:
            id:
              type: string
              description: The store's order code.
            ref:
              type: [string, "null"]
            product_id:
              type: [string, "null"]
            product_name:
              type: string
            package_code:
              type: [string, "null"]
            package_label:
              type: string
            player_id:
              type: string
            uid:
              type: [string, "null"]
            nickname:
              type: [string, "null"]
            quantity:
              type: integer
            amount:
              type: number
            status:
              type: string
              enum: [Pending, Processing, Delivered, Completed, Cancelled, Failed, On Hold]
            created_at:
              type: string
              description: When the order was placed.
