openapi: 3.1.1

info:
  title: OpenShop Public API
  version: "1.0.0"
  summary: The externally-consumable half of OpenShop.mn — catalog, checkout, orders, payment links and the Buildry Chat connector.
  description: |
    This file is the **contract**, not a tour of the codebase. It describes only what an
    integrator outside OpenShop may call:

    * the **token-free public shop surface** a storefront or a chat commerce client uses
      (`/shop/products*`, `/shop/checkout`, `/shop/pay/*`, …);
    * the **API-key surface** a merchant's POS/ERP uses (`Authorization: Bearer osk_live_…`,
      migrations 0137/0138);
    * the **merchant integration console** that mints those keys and registers webhook
      endpoints (browser session only — a credential never administers credentials);
    * the **Buildry Chat connector** surface (HMAC-signed, `api.openshop.mn` only).

    Deliberately **not** described here, because they are not a public contract:
    the superadmin back-office (`/admin/*`), the internal provisioning routes
    (`/api/v1/internal/*`), the payment-rail callbacks (`/api/v1/pay/*`, IP-allowlisted at
    the edge), the identity/OIDC surface (`/oauth2/*`, `/.well-known/openid-configuration`,
    `/api/v1/auth/*`, `/api/v1/me/*`), the apex discovery and marketing reads
    (`/api/v1/{feed,search,shops,discover/home,blog,platform/stats,…}`), and the
    session-only merchant console CRUD (catalog writes, coupons, storefront design, Wire
    KYB, e-barimt configuration, staff, roles, domains, pages, bundles, payable
    administration). Those may change without a major version.

    **Error envelope.** Every error is `{"error": "<message, usually Mongolian>"}`. Errors
    that an integrator is expected to branch on additionally carry a stable machine-readable
    `code` (and sometimes extra fields such as `remaining` or `required_scope`). Codes are
    additive: a new one may appear, an existing one is never renamed. **Never branch on the
    message text** — it is UI copy and gets edited.

    **Money and time.** All amounts are Mongolian tögrög as integers (`*_mnt`), never
    decimals. Timestamps are RFC 3339; most are rendered in `Asia/Ulaanbaatar` (`+08:00`).

    **Versioning.** The major version lives in the URL (`/v1`). Additive change (a new
    optional field, a new error code, a new endpoint) happens inside `v1`. A breaking
    change means `/v2`. Removal follows the deprecation policy: `Deprecation` (RFC 9745) +
    `Sunset` (RFC 8594) + a `Link` to the successor, at least six months' notice, then
    `410 Gone` with code `endpoint_sunset`.
  contact:
    name: OpenShop platform team
    url: https://openshop.mn
  license:
    name: Proprietary
    identifier: LicenseRef-OpenShop-Proprietary

externalDocs:
  description: Integrator guide (Mongolian) — docs/OPEN-API-PLATFORM.md §10
  url: https://github.com/usukhv/openshop/blob/main/docs/OPEN-API-PLATFORM.md

servers:
  - url: https://{slug}.openshop.mn/api/v1
    description: |
      The shop's own host. The shop is identified by the subdomain, so the token-free
      public surface works here with no credential at all.
    variables:
      slug:
        default: demo
        description: The shop handle, e.g. `demo` in `demo.openshop.mn`.
  - url: https://api.openshop.mn/v1
    description: |
      The canonical API host. There is no shop in the hostname here, so every shop-scoped
      call must present an API key — the key names its own shop. This is also the only host
      that serves the platform-level connector surface.

tags:
  - name: Catalog
    description: Public, read-only reads of one shop's catalog. No credential required.
  - name: Checkout
    description: Creating and following an order. Public by default; never CORS-enabled.
  - name: Payment links
    description: "«Төлбөрийн холбоос» — a standalone payable whose UUID is a capability token."
  - name: Orders
    description: The merchant's own order data. API key (or console session) with `order:read` / `order:write`.
  - name: Inventory
    description: Price and stock writes a POS performs with `product:write`.
  - name: Integration console
    description: Minting API keys and registering webhook endpoints. Browser session only.
  - name: Connector
    description: The Buildry Chat ↔ OpenShop pairing protocol. HMAC-signed, `api.openshop.mn` only.

security: []

paths:
  # ───────────────────────────── Catalog (public) ─────────────────────────────
  /shop/products:
    get:
      tags: [Catalog]
      operationId: listProducts
      summary: List the shop's products
      description: |
        Returns active products. A caller holding a `product:write` API key (or a console
        session with that permission) additionally sees drafts.

        `?q=` runs the Mongolian search engine rather than a plain filter; `hint` reports
        how the result was produced. Cross-origin browser calls are refused unless the
        origin is named in `PUBLIC_API_CORS_ORIGINS` (default: none — this surface is
        server-to-server).
      security:
        - {}
        - apiKey: []
        - session: []
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - name: q
          in: query
          description: Free-text search over title and description.
          required: false
          schema: { type: string }
        - name: category
          in: query
          description: Shop-category id; the subtree is included. An unparseable value is ignored, not refused.
          required: false
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The matching products.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [products, hint]
                properties:
                  products:
                    type: array
                    items: { $ref: '#/components/schemas/Product' }
                  hint:
                    type: string
                    description: |
                      How the search answered. `""` = an ordinary listing or an exact match,
                      `relaxed` = no row matched every word so any-word matching was used,
                      `phone` = the query looked like a telephone number.
                    enum: ["", relaxed, phone]
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/InvalidAPIKey' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/products/{id}:
    get:
      tags: [Catalog]
      operationId: getProduct
      summary: Read one product
      description: One product by id. Drafts are visible only to a caller with `product:write`, or through a `pv` preview token.
      security:
        - {}
        - apiKey: []
        - session: []
      parameters:
        - $ref: '#/components/parameters/ProductId'
        - name: pv
          in: query
          description: A single-use draft-preview token minted by the merchant console. Lets an anonymous caller see one unpublished product.
          required: false
          schema: { type: string }
      responses:
        '200':
          description: The product. `recent_paid_count` is present here (and only here) when at least two buyers paid for it in the last seven days.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/InvalidAPIKey' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/products/{id}/variants:
    get:
      tags: [Catalog]
      operationId: listVariants
      summary: List a product's variants and its option axes
      description: |
        `cost_mnt` — the merchant's buying price — is **never** returned to an API key,
        whatever its scopes, nor to an anonymous caller. Only a console session that could
        edit the variant sees it.

        An unknown product id is not an error here: the response is simply empty.
      security:
        - {}
        - apiKey: []
        - session: []
      parameters:
        - $ref: '#/components/parameters/ProductId'
      responses:
        '200':
          description: The product's variants and options.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [variants, options]
                properties:
                  variants:
                    type: array
                    items: { $ref: '#/components/schemas/Variant' }
                  options:
                    type: array
                    items: { $ref: '#/components/schemas/ProductOption' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/InvalidAPIKey' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/products/{id}/reviews:
    get:
      tags: [Catalog]
      operationId: listProductReviews
      summary: Read a product's reviews
      description: Up to 30 most recent reviews plus the aggregate.
      parameters:
        - $ref: '#/components/parameters/ProductId'
      responses:
        '200':
          description: The product's review summary and recent reviews.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReviewList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/reviews:
    get:
      tags: [Catalog]
      operationId: listShopReviews
      summary: Read the shop's reputation
      description: Up to 30 most recent shop reviews plus the aggregate.
      responses:
        '200':
          description: The shop's review summary and recent reviews.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReviewList' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/categories:
    get:
      tags: [Catalog]
      operationId: listShopCategories
      summary: List the shop's category tree
      description: The whole tree in one call — parent links are in `parent_id`, ordering in `position`.
      security:
        - {}
        - apiKey: []
        - session: []
      responses:
        '200':
          description: Every category of this shop.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ShopCategory' }
        '401': { $ref: '#/components/responses/InvalidAPIKey' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/categories/{id}:
    get:
      tags: [Catalog]
      operationId: getShopCategory
      summary: Read one category
      description: One node of the shop's category tree, including its product count and taxonomy mapping.
      security:
        - {}
        - apiKey: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          description: Category id.
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The category.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ShopCategory' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/InvalidAPIKey' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/bundles/public/{id}:
    get:
      tags: [Catalog]
      operationId: getPublicBundle
      summary: Read an active bundle
      description: |
        Display-ready: component titles, sale-aware unit prices and per-component
        availability are resolved server-side. Only `active` bundles are visible.
      security:
        - {}
        - apiKey: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          description: Bundle id.
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The bundle.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Bundle' }
        '401': { $ref: '#/components/responses/InvalidAPIKey' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/suggest:
    get:
      tags: [Catalog]
      operationId: suggestShopProducts
      summary: Search-as-you-type over this shop
      description: |
        At most eight confident hits. A query shorter than two characters answers `200`
        with an empty list rather than an error. Results are cached for five minutes on the
        normalised query.
      parameters:
        - name: q
          in: query
          required: true
          description: The partial query. Fewer than two characters returns an empty list.
          schema: { type: string }
      responses:
        '200':
          description: Suggestions, most relevant first.
          content:
            application/json:
              schema:
                type: object
                required: [suggestions]
                properties:
                  suggestions:
                    type: array
                    items: { $ref: '#/components/schemas/Suggestion' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/feed.csv:
    get:
      tags: [Catalog]
      operationId: getProductFeedCSV
      summary: Product feed (CSV)
      description: |
        The shop's own catalogue as a Google Merchant Center / Meta Commerce Manager
        product feed. Paste the URL into Merchant Center («Scheduled fetch») or Commerce
        Manager («Data feed» → scheduled); no credential is needed.

        **Rows.** Every `active` product that has an active variant — including products
        hidden from «Нээх» discovery (this is the merchant's feed, not the platform's).
        Drafts never appear. A product with one variant is one row whose `id` is the
        **product UUID** — the same `retailer_id` the Facebook catalogue sync and the Pixel's
        `content_ids` use. A product with two or more active variants fans out to one row
        per variant: `id` = variant UUID, `item_group_id` = product UUID, title suffixed with
        the option values, price/availability/image per variant. `link` is always the
        product page on the shop's canonical host (its custom domain when active).

        **Columns**, in order: `id`, `item_group_id`, `title`, `description` (plain text,
        ≤5000 characters), `availability` (`in stock` / `out of stock` — an untracked
        variant is in stock), `condition` (always `new`), `price` (`"<integer> MNT"`),
        `sale_price` (present only while the sale is active; computed exactly like the
        storefront), `sale_price_effective_date` (only when the sale has an end date),
        `link`, `image_link`, `additional_image_link` (comma-joined, at most 10), `brand`
        (the shop's display name), `product_type` (the shop-category trail),
        `google_product_category` (the reference taxonomy path, English; empty when the
        shop category is not linked). Nothing private to the merchant — cost, stock counts —
        is ever included.

        **Caching.** The body is cached per shop and format for five minutes (`Cache-Control:
        public, max-age=300`) and carries an `ETag`; send it back as `If-None-Match` to get
        `304`. A change to the catalogue therefore shows in the feed within five minutes.
        UTF-8 with a byte-order mark; RFC 4180 quoting. Per-client budget of 30 requests
        per minute.
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The feed, streamed. `Content-Disposition` is `inline` — this is a document for a crawler, not a download.
          headers:
            ETag: { $ref: '#/components/headers/FeedETag' }
            Cache-Control: { $ref: '#/components/headers/FeedCacheControl' }
            Content-Disposition: { $ref: '#/components/headers/FeedDisposition' }
          content:
            text/csv:
              schema: { type: string }
        '304': { $ref: '#/components/responses/FeedNotModified' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/feed.xml:
    get:
      tags: [Catalog]
      operationId: getProductFeedXML
      summary: Product feed (RSS 2.0, Google `g:` namespace)
      description: |
        The same rows as `/shop/feed.csv`, as the RSS 2.0 dialect Merchant Center and
        Commerce Manager read: one `<item>` per row, `title`/`description`/`link` as RSS
        elements and every other column as `<g:…>` under `xmlns:g="http://base.google.com/ns/1.0"`;
        additional images are repeated `<g:additional_image_link>` elements. Row rules,
        caching and the per-client budget are as for the CSV variant.
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: The feed, streamed.
          headers:
            ETag: { $ref: '#/components/headers/FeedETag' }
            Cache-Control: { $ref: '#/components/headers/FeedCacheControl' }
            Content-Disposition: { $ref: '#/components/headers/FeedDisposition' }
          content:
            application/rss+xml:
              schema: { type: string }
        '304': { $ref: '#/components/responses/FeedNotModified' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ───────────────────────────── Checkout ─────────────────────────────
  /shop/checkout:
    post:
      tags: [Checkout]
      operationId: createCheckout
      summary: Create an order and open a payment
      description: |
        Guest-first: no credential is required. The response carries the order and, when the
        shop is live on the payment rail, an invoice with a hosted `checkout_url` the payer
        is sent to.

        **Server-side pricing.** Prices in the request are ignored; sale price, bundle
        composition, coupon and delivery fee are all resolved on the server, in that order.

        **Not transactional with stock.** An order is created without reserving stock, but a
        line whose variant is already sold out is refused with `out_of_stock` /
        `insufficient_stock` before any order exists.

        **Go-live gate.** If the shop is not an active merchant on the payment rail the call
        fails with `payments_not_ready` and **no order is created** — the buyer's cart is
        intact and they should be told to contact the shop.

        **Idempotency.** Send `Idempotency-Key` (see the header description). Without it a
        retried POST creates a second order.

        **Connector guard.** A request that identifies itself with `X-Connector-Id` must also
        carry a valid `X-Connector-Signature` and belong to a linked shop. A request with no
        `X-Connector-Id` is the shop's own storefront and passes untouched — unless the
        platform has enforcement on and the User-Agent is Buildry's, in which case it is
        refused with `connector_required`.

        This endpoint is **never** CORS-enabled. Call it from your server.
      security:
        - {}
        - apiKey: []
        - connectorHmac: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/ConnectorId'
        - $ref: '#/components/parameters/ConnectorSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutRequest' }
      responses:
        '201':
          description: The order was created. `invoice.checkout_url` is the hosted payment page.
          headers:
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [order]
                properties:
                  order: { $ref: '#/components/schemas/Order' }
                  invoice: { $ref: '#/components/schemas/Invoice' }
        '400':
          description: |
            The request is not acceptable. Plain-envelope validation failures (bad
            `product_id`, non-positive `qty`, more than 100 items or 20 bundles, an
            organisation ТТД that is not 11–14 digits, a malformed citizen register) carry no
            `code`; a malformed `Idempotency-Key` carries `idempotency_key_invalid`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          description: A connector-signed request failed verification.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalidSignature:
                  value: { error: invalid_signature, code: invalid_signature }
        '403':
          description: The connector is not permitted to check out for this shop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                notLinked:
                  summary: Signed, but the shop has no active connector link
                  value: { error: connector_not_linked, code: connector_not_linked }
                required:
                  summary: Enforcement is on and a Buildry client called without signing
                  value: { error: connector_required, code: connector_required }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '409':
          description: |
            The order could not be created as asked. `out_of_stock` and `insufficient_stock`
            also carry `title` and `remaining`. Coupon failures use the plain envelope with a
            Mongolian explanation and no `code`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                insufficientStock:
                  value:
                    error: "«Цамц» барааны үлдэгдэл хүрэлцэхгүй байна (үлдсэн: 2)."
                    code: insufficient_stock
                    title: Цамц
                    remaining: 2
                outOfStock:
                  value: { error: "«Цамц» бараа дууссан байна.", code: out_of_stock, title: Цамц, remaining: 0 }
                paymentsNotReady:
                  summary: The shop cannot accept online payment yet — no order was created
                  value:
                    error: Энэ дэлгүүр одоогоор онлайн төлбөр хүлээн авах боломжгүй байна. Дэлгүүртэй шууд холбогдож захиалаарай.
                    code: payments_not_ready
                idempotencyInProgress:
                  summary: An earlier request with this key is still running
                  value: { error: Ижил хүсэлт боловсруулагдаж байна. Түр хүлээгээд дахин шалгана уу., code: idempotency_in_progress }
        '413':
          description: The request body exceeded the connector's 64 KiB limit.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReuse' }
        '429':
          description: |
            Too many checkouts. A **keyed** caller is counted against its own key's write
            budget and gets the coded envelope plus `Retry-After`; an anonymous caller is
            counted per client address (30 a minute) and gets the plain envelope with no
            `code`.
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                keyed:
                  value: { error: хэт олон хүсэлт — түр хүлээгээд дахин оролдоно уу, code: rate_limited }
                anonymous:
                  value: { error: хэт олон хүсэлт, түр хүлээнэ үү }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/RailUnavailable' }

  /shop/checkout/{id}:
    get:
      tags: [Checkout]
      operationId: getCheckoutStatus
      summary: Poll an order's payment status
      description: |
        The order id doubles as the receipt token: it is an unguessable UUID and the response
        carries no buyer data. Treat `status: "paid"` from **this** endpoint as the only proof
        of payment — a hosted-checkout redirect back to your site is not proof.

        Same connector guard as `POST /shop/checkout`. Never CORS-enabled.
      security:
        - {}
        - apiKey: []
        - connectorHmac: []
      parameters:
        - name: id
          in: path
          required: true
          description: The order id returned by `POST /shop/checkout`.
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/ConnectorId'
        - $ref: '#/components/parameters/ConnectorSignature'
      responses:
        '200':
          description: The order. `paid_at` appears once the rail has settled it.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: A connector-signed request failed verification.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: The connector is not linked to this shop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/orders/lookup:
    post:
      tags: [Checkout]
      operationId: lookupGuestOrder
      summary: Look up a guest's own order
      description: |
        The buyer-facing receipt: it needs **both** the unguessable order id and the phone
        number entered at checkout. Every miss — wrong phone, wrong shop, unknown id — is the
        same `404`, so the endpoint reveals nothing. Rate-limited to 10 requests a minute per
        client address.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id, phone]
              properties:
                order_id: { type: string, format: uuid }
                phone:
                  type: string
                  description: The phone entered at checkout.
      responses:
        '200':
          description: The buyer's view of the order, including its lines and a resumable payment URL when it is still payable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BuyerOrder' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimitedPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/coupons/quote:
    post:
      tags: [Checkout]
      operationId: quoteCoupon
      summary: Price a coupon against a cart
      description: |
        A preview only — nothing is redeemed. With `code` empty the shop's best automatically
        applied discount is previewed instead, and "no coupon applies" is a `200` with a zero
        discount, not an error.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CouponQuoteRequest' }
      responses:
        '200':
          description: The discount this cart would receive.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CouponQuote' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '409':
          description: The coupon exists but does not apply — inactive, exhausted, below the minimum spend, already used by this buyer, first-order-only, or producing no benefit. Mongolian message, no `code`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/ebarimt/tin-lookup:
    get:
      tags: [Checkout]
      operationId: lookupTIN
      summary: Resolve an organisation registration number to its ТТД
      description: |
        A B2B fiscal receipt needs an 11–14 digit ТТД; a company's 7-digit registration number
        is not one. Resolve it here first. Rate-limited to 60 requests a minute per client
        address.
      parameters:
        - name: regNo
          in: query
          required: true
          description: The 7-digit organisation registration number.
          schema: { type: string }
      responses:
        '200':
          description: The resolved ТТД.
          content:
            application/json:
              schema:
                type: object
                required: [tin]
                properties:
                  tin: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimitedPlain' }
        '503':
          description: The tax authority lookup is not configured or unreachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /shop/newsletter:
    post:
      tags: [Catalog]
      operationId: subscribeNewsletter
      summary: Subscribe an address to the shop's newsletter
      description: |
        **Always answers `200`.** An invalid address, a duplicate, and a rate-limited probe are
        indistinguishable by design — the endpoint must not become an address oracle.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Accepted (whether or not a row was written).
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }

  # ───────────────────────────── Payment links ─────────────────────────────
  /shop/pay/{id}:
    get:
      tags: [Payment links]
      operationId: getPaymentLink
      summary: Read a payment link
      description: |
        The link id is a capability token: whoever holds the URL may open it. A cancelled or
        expired link still answers `200` with its status, so the payer sees "no longer valid"
        rather than a `404` that reads as a typo. Another shop's link is simply not found.
      parameters:
        - $ref: '#/components/parameters/PayableId'
      responses:
        '200':
          description: The payment link, display-ready.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicPayable' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/pay/{id}/status:
    get:
      tags: [Payment links]
      operationId: getPaymentLinkStatus
      summary: Poll a payment link's status
      description: The small poll a return-from-hosted-checkout page runs. Only a server-confirmed `paid` counts.
      parameters:
        - $ref: '#/components/parameters/PayableId'
      responses:
        '200':
          description: The link's current status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PayableStatus' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/pay/{id}/checkout:
    post:
      tags: [Payment links]
      operationId: payPaymentLink
      summary: Open (or reuse) the payment for a link
      description: |
        Opens the hosted payment page for a payment link. Calling it again while the previous
        intent is still open **reuses that intent** rather than opening a second payment
        window, so this endpoint is already idempotent for the duration of the intent
        (`INVOICE_TTL`, 15 minutes) and takes no `Idempotency-Key`.
      parameters:
        - $ref: '#/components/parameters/PayableId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PayableCheckoutRequest' }
      responses:
        '201':
          description: The invoice to pay.
          content:
            application/json:
              schema:
                type: object
                required: [invoice]
                properties:
                  invoice: { $ref: '#/components/schemas/Invoice' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '409':
          description: The link can no longer be paid, or the shop is not live on the rail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                payableClosed:
                  value: { error: Энэ төлбөрийн холбоос хүчингүй болсон байна. Дэлгүүртэй холбогдоно уу., code: payable_closed }
                paymentsNotReady:
                  value: { error: Энэ дэлгүүр одоогоор онлайн төлбөр хүлээн авах боломжгүй байна. Дэлгүүртэй шууд холбогдож захиалаарай., code: payments_not_ready }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502': { $ref: '#/components/responses/RailUnavailable' }

  # ───────────────────────────── Orders (API key) ─────────────────────────────
  /shop/orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: List the shop's orders
      description: Requires `order:read`. Unknown filter values are ignored rather than refused.
      security:
        - apiKey: []
        - session: []
      parameters:
        - name: limit
          in: query
          description: 1–1000. Default 200.
          required: false
          schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
        - name: status
          in: query
          required: false
          schema: { $ref: '#/components/schemas/OrderStatus' }
        - name: zone
          in: query
          required: false
          schema: { $ref: '#/components/schemas/DeliveryZone' }
        - name: source
          in: query
          description: First-touch traffic source; `direct` means "no source recorded".
          required: false
          schema: { type: string }
        - name: q
          in: query
          description: Free text over buyer name, phone and item titles.
          required: false
          schema: { type: string }
        - name: from
          in: query
          description: Inclusive start day, `YYYY-MM-DD`.
          required: false
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        - name: to
          in: query
          description: Inclusive end day, `YYYY-MM-DD`.
          required: false
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      responses:
        '200':
          description: A page of orders plus the unpaged total.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [orders, total]
                properties:
                  orders:
                    type: array
                    items: { $ref: '#/components/schemas/OrderRow' }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/orders/export:
    get:
      tags: [Orders]
      operationId: exportOrders
      summary: Export the full order history
      description: |
        The whole history, unpaged. `?format=json` returns rows; anything else returns a
        UTF-8 CSV (with a BOM, so Excel renders Cyrillic) as a file attachment. Requires
        `order:read`.
      security:
        - apiKey: []
        - session: []
      parameters:
        - name: format
          in: query
          description: "`json` for rows; omitted or anything else for a CSV attachment."
          required: false
          schema: { type: string, enum: [json, csv] }
      responses:
        '200':
          description: The export.
          headers:
            Content-Disposition:
              description: Present on the CSV variant, e.g. `attachment; filename="orders-demo-2026-09-16.csv"`.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [orders, total]
                properties:
                  orders:
                    type: array
                    items: { $ref: '#/components/schemas/OrderExportRow' }
                  total: { type: integer }
            text/csv:
              schema: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/orders/{id}:
    get:
      tags: [Orders]
      operationId: getOrder
      summary: Read one order in full
      description: |
        The authoritative record, including the buyer's contact details, the delivery address,
        the fiscal identifiers and every line. Requires `order:read`. This is where a webhook
        receiver comes for the detail the thin webhook payload deliberately omits.
      security:
        - apiKey: []
        - session: []
      parameters:
        - $ref: '#/components/parameters/OrderId'
      responses:
        '200':
          description: The order.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

    patch:
      tags: [Orders]
      operationId: fulfilOrder
      summary: Mark an order delivered, or undo it
      description: |
        Flips `paid` ⇄ `fulfilled`. The body names the **target** state rather than toggling,
        so a double tap or a stale tab is harmless. Any other current state is a `409`.
        Requires `order:write`.
      security:
        - apiKey: []
        - session: []
      parameters:
        - $ref: '#/components/parameters/OrderId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fulfilled]
              properties:
                fulfilled:
                  type: boolean
                  description: "`true` = paid → fulfilled. `false` = fulfilled → paid."
      responses:
        '200':
          description: The order's new state.
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id: { type: string, format: uuid }
                  status: { $ref: '#/components/schemas/OrderStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '409':
          description: The order is not in a state that allows this transition.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/variants/{id}:
    patch:
      tags: [Inventory]
      operationId: patchVariant
      summary: Update a variant's price or stock
      description: |
        The POS write. Requires `product:write`.

        **An API key may send only `sku`, `barcode`, `price_mnt`, `stock_qty` and `version`.**
        Any other field — `cost_mnt` above all — is refused with `validation_error` and the
        offending `field`. Fields the body omits are left alone, so a partial patch is safe.
        A console session, by contrast, replaces the whole variant.

        `version` is optimistic locking: send the one you read and a concurrent edit answers
        `409`. Omit it and the current version is used.
      security:
        - apiKey: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          description: Variant id.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VariantPatch' }
      responses:
        '200':
          description: The stored variant. `cost_mnt` is absent for a keyed caller.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Variant' }
        '400':
          description: A field is not acceptable, or an API key sent a console-only field.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                forbiddenField:
                  value: { error: "энэ талбарыг API түлхүүрээр өөрчлөх боломжгүй: cost_mnt", code: validation_error, field: cost_mnt }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFoundPlain' }
        '409':
          description: The variant changed under you (`version` mismatch), or the SKU is taken.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ───────────────────────────── Integration console ─────────────────────────────
  /shop/api-keys:
    get:
      tags: [Integration console]
      operationId: listAPIKeys
      summary: List the shop's API keys
      description: Revoked keys are included — the list is the history. Requires a console session with `integration:manage`; an API key may never call this.
      security:
        - session: []
      responses:
        '200':
          description: Every key this shop has ever minted.
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items: { $ref: '#/components/schemas/APIKey' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

    post:
      tags: [Integration console]
      operationId: createAPIKey
      summary: Mint an API key
      description: |
        **The plaintext secret is returned exactly once.** Afterwards only the display prefix
        remains. Scopes are immutable: rotation means minting a new key and revoking the old
        one.

        Two limits apply. A key may only carry `product:read`, `product:write`,
        `product:delete`, `order:read`, `order:write` — never a permission that administers
        people or credentials. And an actor may only put on a key the permissions they hold
        themselves (the grant ceiling).
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name:
                  type: string
                  description: Display name, clamped to 60 characters.
                  maxLength: 60
                scopes:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/KeyScope' }
      responses:
        '201':
          description: The key, with its secret shown for the only time.
          content:
            application/json:
              schema:
                type: object
                required: [key, secret]
                properties:
                  key: { $ref: '#/components/schemas/APIKey' }
                  secret:
                    type: string
                    description: "`osk_live_<43 chars>` (`osk_test_` in development). Store it now."
                    examples: ["osk_live_7Nq2rX9mB4tV1kZ8cY6wP3sL0hJ5dF2gA7nQ1eR4uT8"]
        '400':
          description: The name is empty, or a scope may never be granted to a key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                scopeInvalid:
                  value: { error: API түлхүүрт олгох боломжгүй эрх байна, code: api_key_scope_invalid }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '409':
          description: The shop already holds the maximum number of live keys.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                limit:
                  value: { error: Түлхүүрийн тоо дээд хэмжээндээ хүрсэн байна, code: api_key_limit_reached }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/api-keys/scopes:
    get:
      tags: [Integration console]
      operationId: listAPIKeyScopes
      summary: List the scopes this actor may grant
      description: The closed key-scope allow-list intersected with what the caller holds, with display labels — so the console never renders a checkbox that cannot be ticked.
      security:
        - session: []
      responses:
        '200':
          description: The grantable scopes.
          content:
            application/json:
              schema:
                type: object
                required: [scopes]
                properties:
                  scopes:
                    type: array
                    items:
                      type: object
                      required: [key, label, category]
                      properties:
                        key: { $ref: '#/components/schemas/KeyScope' }
                        label: { type: string, description: Mongolian display name. }
                        category: { type: string }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/api-keys/{id}:
    delete:
      tags: [Integration console]
      operationId: revokeAPIKey
      summary: Revoke an API key
      description: Immediate — the very next request presenting it is `401`.
      security:
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          description: Key id.
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Revoked. }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '404': { $ref: '#/components/responses/NotFoundCoded' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks:
    get:
      tags: [Integration console]
      operationId: listWebhooks
      summary: List the shop's webhook endpoints
      description: Every endpoint this shop has registered, with its health counters. Secrets are never returned.
      security:
        - session: []
      responses:
        '200':
          description: The registered endpoints.
          content:
            application/json:
              schema:
                type: object
                required: [endpoints]
                properties:
                  endpoints:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

    post:
      tags: [Integration console]
      operationId: createWebhook
      summary: Register a webhook endpoint
      description: |
        **The signing secret is returned exactly once.** The URL must be `https` and must
        resolve to a public address — private, loopback and link-local targets are refused
        (`webhook_url_invalid`), and the same check runs again at dial time.

        `ping` is not subscribable; it is only ever produced by the console's test button.
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri, maxLength: 2048 }
                description: { type: string, maxLength: 200 }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/WebhookEventType' }
      responses:
        '201':
          description: The endpoint, with its secret shown for the only time.
          content:
            application/json:
              schema:
                type: object
                required: [endpoint, secret]
                properties:
                  endpoint: { $ref: '#/components/schemas/WebhookEndpoint' }
                  secret: { type: string, description: The HMAC signing secret. Store it now. }
        '400':
          description: The URL or the event list is not acceptable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                urlInvalid:
                  value: { error: Хаяг https:// байх ба нийтэд нээлттэй сервер рүү заасан байх ёстой, code: webhook_url_invalid }
                eventsInvalid:
                  value: { error: Дор хаяж нэг зөв үйл явдал сонгоно уу, code: webhook_events_invalid }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '409':
          description: The shop already has the maximum number of endpoints (5).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                limit:
                  value: { error: Endpoint-ийн тоо дээд хэмжээндээ хүрсэн байна, code: webhook_limit_reached }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks/events:
    get:
      tags: [Integration console]
      operationId: listWebhookEventTypes
      summary: List the subscribable event catalog
      description: Display-ready — the console renders what this returns and keeps no label map of its own.
      security:
        - session: []
      responses:
        '200':
          description: Every event type, with Mongolian names.
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      required: [key, label, description]
                      properties:
                        key: { $ref: '#/components/schemas/WebhookEventTypeOrPing' }
                        label: { type: string }
                        description: { type: string }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks/{id}:
    patch:
      tags: [Integration console]
      operationId: patchWebhook
      summary: Update a webhook endpoint
      description: Omitted fields are left alone. Setting `status` back to `active` is how a merchant revives an auto-disabled endpoint.
      security:
        - session: []
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri, maxLength: 2048 }
                description: { type: string, maxLength: 200 }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/WebhookEventType' }
                status:
                  type: string
                  enum: [active, disabled]
      responses:
        '200':
          description: The updated endpoint.
          content:
            application/json:
              schema:
                type: object
                required: [endpoint]
                properties:
                  endpoint: { $ref: '#/components/schemas/WebhookEndpoint' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '404': { $ref: '#/components/responses/NotFoundCoded' }
        '500': { $ref: '#/components/responses/InternalError' }

    delete:
      tags: [Integration console]
      operationId: deleteWebhook
      summary: Delete a webhook endpoint
      description: Removes the endpoint and stops all future deliveries to it. Its delivery log goes with it — disable the endpoint instead if you want to keep the history.
      security:
        - session: []
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '204': { description: Deleted. }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '404': { $ref: '#/components/responses/NotFoundCoded' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks/{id}/rotate:
    post:
      tags: [Integration console]
      operationId: rotateWebhookSecret
      summary: Rotate an endpoint's signing secret
      description: |
        Mints a new secret and keeps the previous one valid for an overlap window, during
        which every delivery carries **two** `v1` signatures — so the receiver can be
        redeployed without losing a delivery.
      security:
        - session: []
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: The new secret and when the old one stops verifying.
          content:
            application/json:
              schema:
                type: object
                required: [secret, prev_expires_at, prev_valid_hours]
                properties:
                  secret: { type: string }
                  prev_expires_at: { type: string, format: date-time }
                  prev_valid_hours: { type: integer }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '404': { $ref: '#/components/responses/NotFoundCoded' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks/{id}/test:
    post:
      tags: [Integration console]
      operationId: testWebhook
      summary: Queue a test delivery
      description: |
        Queues a `ping` through the ordinary dispatcher, so a passing test proves the whole
        chain rather than a special case. Five per minute per endpoint.
      security:
        - session: []
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '202':
          description: Queued. Follow it in the delivery log.
          content:
            application/json:
              schema:
                type: object
                required: [delivery_id]
                properties:
                  delivery_id: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '404': { $ref: '#/components/responses/NotFoundCoded' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks/{id}/deliveries:
    get:
      tags: [Integration console]
      operationId: listWebhookDeliveries
      summary: Read an endpoint's delivery log
      description: The log is history — a replay never rewrites an existing row. An unknown `status` filter simply shows everything.
      security:
        - session: []
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - name: status
          in: query
          required: false
          schema: { $ref: '#/components/schemas/DeliveryStatus' }
      responses:
        '200':
          description: The deliveries.
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '500': { $ref: '#/components/responses/InternalError' }

  /shop/webhooks/{id}/deliveries/{did}/replay:
    post:
      tags: [Integration console]
      operationId: replayWebhookDelivery
      summary: Replay a delivery
      description: Queues a **new** delivery with the same payload, pointing back at the original. The original row is untouched.
      security:
        - session: []
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - name: did
          in: path
          required: true
          description: The delivery to replay.
          schema: { type: string, format: uuid }
      responses:
        '202':
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                required: [delivery_id]
                properties:
                  delivery_id: { type: string, format: uuid }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/UnauthorizedPlain' }
        '403': { $ref: '#/components/responses/ForbiddenPlain' }
        '404': { $ref: '#/components/responses/NotFoundCoded' }
        '500': { $ref: '#/components/responses/InternalError' }

  # ───────────────────────────── Connector (Buildry Chat) ─────────────────────────────
  /connector/chat/tenants:
    get:
      tags: [Connector]
      operationId: connectorSearchTenants
      summary: Search shops available for pairing
      description: |
        Served on `api.openshop.mn` only. Every call must carry `X-Connector-Id: buildry_chat`
        and a valid `X-Connector-Signature`; the whole surface answers `404` until the shared
        secret is configured on this side.
      security:
        - connectorHmac: []
      parameters:
        - $ref: '#/components/parameters/ConnectorIdRequired'
        - $ref: '#/components/parameters/ConnectorSignatureRequired'
        - name: q
          in: query
          description: Shop id, handle or name.
          required: false
          schema: { type: string }
        - name: limit
          in: query
          description: 1–50. Default 20.
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
      responses:
        '200':
          description: Matching shops.
          content:
            application/json:
              schema:
                type: object
                required: [tenants]
                properties:
                  tenants:
                    type: array
                    items: { $ref: '#/components/schemas/ConnectorTenant' }
        '401': { $ref: '#/components/responses/ConnectorUnauthorized' }
        '404': { $ref: '#/components/responses/ConnectorDisabled' }
        '500': { $ref: '#/components/responses/ConnectorInternal' }

  /connector/chat/links:
    post:
      tags: [Connector]
      operationId: connectorCreateLink
      summary: Create (or confirm) a pairing
      description: |
        Idempotent on `link_id`: replaying the same link answers `200` with the stored row,
        while a first write answers `201`. The shop may be named by `tenant_id` or by
        `tenant_slug`.
      security:
        - connectorHmac: []
      parameters:
        - $ref: '#/components/parameters/ConnectorIdRequired'
        - $ref: '#/components/parameters/ConnectorSignatureRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ConnectorLinkRequest' }
      responses:
        '200':
          description: The pairing already existed and is unchanged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorLink' }
        '201':
          description: The pairing was created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorLink' }
        '400':
          description: A field is malformed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorError' }
              examples:
                invalidLinkId: { value: { error: invalid_link_id } }
                invalidWorkspaceId: { value: { error: invalid_workspace_id } }
                invalidLinkedBy: { value: { error: invalid_linked_by } }
                invalidBody: { value: { error: invalid_body } }
        '401': { $ref: '#/components/responses/ConnectorUnauthorized' }
        '404':
          description: No such shop — or the connector surface is switched off.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorError' }
              examples:
                tenantNotFound: { value: { error: tenant_not_found } }
        '409':
          description: The shop is not active, or it is already linked to another workspace.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorError' }
              examples:
                tenantInactive: { value: { error: tenant_inactive } }
                alreadyLinked: { value: { error: already_linked_elsewhere } }
        '413': { $ref: '#/components/responses/ConnectorBodyTooLarge' }
        '500': { $ref: '#/components/responses/ConnectorInternal' }

  /connector/chat/links/{link_id}:
    get:
      tags: [Connector]
      operationId: connectorGetLink
      summary: Read a pairing
      description: The pairing as we hold it, so the connector can detect drift between the two sides.
      security:
        - connectorHmac: []
      parameters:
        - $ref: '#/components/parameters/ConnectorIdRequired'
        - $ref: '#/components/parameters/ConnectorSignatureRequired'
        - $ref: '#/components/parameters/LinkId'
      responses:
        '200':
          description: The pairing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorLink' }
        '401': { $ref: '#/components/responses/ConnectorUnauthorized' }
        '404': { $ref: '#/components/responses/ConnectorLinkNotFound' }
        '500': { $ref: '#/components/responses/ConnectorInternal' }

    delete:
      tags: [Connector]
      operationId: connectorDeleteLink
      summary: Unlink a pairing
      description: |
        Idempotent: unlinking an already-unlinked pairing answers `200` with the row as it
        stands. The row is never deleted — it is marked `unlinked`, so the history survives.
      security:
        - connectorHmac: []
      parameters:
        - $ref: '#/components/parameters/ConnectorIdRequired'
        - $ref: '#/components/parameters/ConnectorSignatureRequired'
        - $ref: '#/components/parameters/LinkId'
      responses:
        '200':
          description: The unlinked pairing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConnectorLink' }
        '401': { $ref: '#/components/responses/ConnectorUnauthorized' }
        '404': { $ref: '#/components/responses/ConnectorLinkNotFound' }
        '500': { $ref: '#/components/responses/ConnectorInternal' }

# ───────────────────────────── Outgoing webhooks ─────────────────────────────
webhooks:
  openshopEvent:
    post:
      tags: [Integration console]
      operationId: receiveOpenShopEvent
      summary: An event OpenShop delivers to a merchant's endpoint
      description: |
        This is the request **we** make to the URL registered at `POST /shop/webhooks`.

        * **At-least-once, no ordering guarantee.** The same event may arrive twice and two
          events may arrive out of order. Deduplicate on `X-OpenShop-Event-Id`, and treat
          `GET /shop/orders/{id}` as the final truth — an `order.expired` can legitimately be
          followed by an `order.paid` when a buyer pays late.
        * **Verify before parsing.** Compute
          `HMAC-SHA256(secret, t + "." + rawBody)` over the **raw** body and compare it to a
          `v1` value in `X-OpenShop-Signature`. Reject a `t` more than five minutes from your
          clock. During a secret rotation the header carries two `v1` values; either matching
          is valid.
        * **Answer 2xx fast.** The request times out after 10 seconds and we read at most
          64 KiB of your response. Acknowledge first, work afterwards.
        * **Retries.** 5xx, 408, 429 and timeouts are retried after roughly
          1m · 5m · 30m · 2h · 6h · 12h · 24h (±20 % jitter), 8 attempts, about 45 hours in
          total. Any other 4xx stops immediately — repeating a request you called malformed
          changes nothing. `410 Gone` disables the endpoint at once; 50 consecutive failures
          disables it too.
        * **The payload is thin and carries no personal data** — no name, phone, address or
          email. Fetch what you need with your API key.
      parameters:
        - name: X-OpenShop-Event-Id
          in: header
          required: false
          description: Stable id of the **event**. Deduplicate on this. Absent on a console `ping`.
          schema: { type: string, format: uuid }
        - name: X-OpenShop-Delivery-Id
          in: header
          required: true
          description: Id of this **attempt's** delivery row. Differs between a delivery and its replay.
          schema: { type: string, format: uuid }
        - name: X-OpenShop-Event-Type
          in: header
          required: true
          schema: { $ref: '#/components/schemas/WebhookEventTypeOrPing' }
        - name: X-OpenShop-Signature
          in: header
          required: true
          description: "`t=<unix seconds>,v1=<hex>` — a second `v1` is appended during a secret rotation."
          schema: { type: string }
          examples:
            single:
              value: t=1750000000,v1=06f35f44b88eceb2f2df6696c68710a8265597b7ca7d225a71d588159757f0d9
        - name: User-Agent
          in: header
          required: true
          schema: { type: string, const: "OpenShop-Webhooks/1.0 (+https://openshop.mn)" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
      responses:
        '200':
          description: Any 2xx means "received". Nothing in the body is read.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: osk_live_<43 chars>
      description: |
        A per-shop API key: `Authorization: Bearer osk_live_…` (`osk_test_…` in development).
        The key names its own shop, so it works on `api.openshop.mn` where there is no shop in
        the hostname. Presenting a key on **another** shop's host is refused with
        `invalid_api_key` — the same answer as an unknown key, so nothing is learned about
        which shop it belongs to. Every request runs under that shop's row-level security:
        another shop's data can never come back.

        Budgets are per key, never per address: 600 reads and 120 writes a minute.
    session:
      type: apiKey
      in: cookie
      name: os_session
      description: |
        The merchant console's browser session, issued by `id.openshop.mn`. Permissions are
        resolved from the signed-in person's role in this shop. Only the console surface
        requires it; it is listed on other operations because a console page may call them
        too.
    connectorHmac:
      type: apiKey
      in: header
      name: X-Connector-Signature
      description: |
        The Buildry Chat connector's shared-secret signature, paired with
        `X-Connector-Id: buildry_chat`.

        `X-Connector-Signature: t=<unix seconds>,v1=<hex>` where
        `v1 = HMAC-SHA256(secret, t + "." + METHOD + " " + PATH + "." + rawBody)`. `PATH` is
        the public path with no query string; binding the method and path stops a captured
        signature being replayed against another endpoint. A timestamp more than five minutes
        from our clock is refused.

  parameters:
    IfNoneMatch:
      name: If-None-Match
      in: header
      required: false
      description: The `ETag` from an earlier response. When it still matches, the answer is `304` with no body.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      required: false
      description: Page size.
      schema: { type: integer, minimum: 1, default: 20 }
    Offset:
      name: offset
      in: query
      required: false
      description: Rows to skip.
      schema: { type: integer, minimum: 0, default: 0 }
    ProductId:
      name: id
      in: path
      required: true
      description: Product id.
      schema: { type: string, format: uuid }
    OrderId:
      name: id
      in: path
      required: true
      description: Order id.
      schema: { type: string, format: uuid }
    PayableId:
      name: id
      in: path
      required: true
      description: Payment-link id — a capability token.
      schema: { type: string, format: uuid }
    WebhookId:
      name: id
      in: path
      required: true
      description: Webhook endpoint id.
      schema: { type: string, format: uuid }
    LinkId:
      name: link_id
      in: path
      required: true
      description: Pairing id, chosen by the connector.
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional. A client-chosen key, 1–255 printable ASCII characters (a UUIDv4 is the
        obvious choice), that makes a retry safe.

        The first request with a key runs; its **successful** response is remembered for 24
        hours and replayed verbatim — same status, same body, plus `Idempotent-Replayed: true`
        — to any later request that presents the same key **and the same body**. Same key with
        a different body is refused `422 idempotency_key_reuse`. A request that arrives while
        the first is still running is refused `409 idempotency_in_progress`; retry in a
        second. A first attempt that **failed** releases the key, so you may fix your request
        and reuse it.

        Scope is per shop. The record lives in a cache with a 24-hour lifetime, not in the
        order transaction: it removes the ordinary duplicate — a retried POST after a
        timeout, a double tap — and it degrades open if the cache is unavailable. Do not rely
        on it as a distributed lock.
      schema: { type: string, minLength: 1, maxLength: 255 }
    ConnectorId:
      name: X-Connector-Id
      in: header
      required: false
      description: Present only on connector traffic. Its presence is what makes the signature mandatory.
      schema: { type: string, const: buildry_chat }
    ConnectorSignature:
      name: X-Connector-Signature
      in: header
      required: false
      description: Required whenever `X-Connector-Id` is present. See the `connectorHmac` security scheme.
      schema: { type: string }
    ConnectorIdRequired:
      name: X-Connector-Id
      in: header
      required: true
      schema: { type: string, const: buildry_chat }
    ConnectorSignatureRequired:
      name: X-Connector-Signature
      in: header
      required: true
      description: See the `connectorHmac` security scheme.
      schema: { type: string }

  headers:
    RateLimitLimit:
      description: The bucket's size, in requests per minute. Sent only to a caller presenting an API key.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests left in the current window. Sent only to a caller presenting an API key.
      schema: { type: integer }
    RateLimitReset:
      description: Seconds until the window resets. Sent only to a caller presenting an API key.
      schema: { type: integer }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }
    IdempotentReplayed:
      description: "`true` when this response was replayed from an earlier request with the same `Idempotency-Key`."
      schema: { type: string, const: "true" }
    FeedETag:
      description: The generation stamp of this feed body. Send it back as `If-None-Match`.
      schema: { type: string }
    FeedCacheControl:
      description: Always `public, max-age=300` — the feed is regenerated at most every five minutes.
      schema: { type: string }
    FeedDisposition:
      description: "`inline; filename=\"<slug>-feed.csv\"` (or `.xml`)."
      schema: { type: string }

  responses:
    FeedNotModified:
      description: The `If-None-Match` value still matches the cached feed. No body; `ETag` and `Cache-Control` are repeated.
      headers:
        ETag: { $ref: '#/components/headers/FeedETag' }
        Cache-Control: { $ref: '#/components/headers/FeedCacheControl' }
    BadRequest:
      description: The request is malformed. Plain envelope with a message.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: A path or body value is not acceptable.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            badId:
              value: { error: буруу id, code: validation_error }
    Unauthorized:
      description: No credential was presented.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unauthorized:
              value: { error: authentication required, code: unauthorized }
    UnauthorizedPlain:
      description: No session cookie.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unauthorized:
              value: { error: authentication required }
    InvalidAPIKey:
      description: A key was presented and it is unknown, revoked, or bound to another shop.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalidKey:
              value: { error: API түлхүүр буруу эсвэл хүчингүй болсон, code: invalid_api_key }
    InsufficientScope:
      description: The caller is authenticated but lacks the permission this route needs. `required_scope` names it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            insufficientScope:
              value: { error: энэ үйлдэлд эрх хүрэхгүй байна, code: insufficient_scope, required_scope: "order:write" }
    ForbiddenPlain:
      description: The signed-in person lacks `integration:manage` in this shop.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            forbidden:
              value: { error: insufficient permission }
    NotFoundPlain:
      description: No such resource — or one you may not see. Plain envelope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFoundCoded:
      description: No such resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            notFound:
              value: { error: endpoint олдсонгүй, code: not_found }
    RateLimited:
      description: The caller's budget is spent.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            rateLimited:
              value: { error: хэт олон хүсэлт — түр хүлээгээд дахин оролдоно уу, code: rate_limited }
    RateLimitedPlain:
      description: Too many requests from this address. Plain envelope, no `code`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyKeyReuse:
      description: This `Idempotency-Key` was already used with a different request body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            reuse:
              value: { error: Энэ Idempotency-Key өөр агуулгатай хүсэлтэд аль хэдийн ашиглагдсан байна., code: idempotency_key_reuse }
    RailUnavailable:
      description: The payment rail rejected the request or was unreachable. Retryable.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            railDown:
              value: { error: Төлбөрийн систем түр алдаа өглөө. Түр хүлээгээд дахин оролдоно уу. }
    InternalError:
      description: Something failed on our side.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ConnectorUnauthorized:
      description: Wrong peer id, bad signature, or a timestamp outside the ±5-minute window.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ConnectorError' }
          examples:
            invalidSignature: { value: { error: invalid_signature } }
    ConnectorDisabled:
      description: The connector surface is switched off on this deployment (no shared secret).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ConnectorError' }
    ConnectorLinkNotFound:
      description: No such pairing.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ConnectorError' }
          examples:
            linkNotFound: { value: { error: link_not_found } }
    ConnectorBodyTooLarge:
      description: The body exceeded 64 KiB.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ConnectorError' }
          examples:
            tooLarge: { value: { error: body_too_large } }
    ConnectorInternal:
      description: Something failed on our side.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ConnectorError' }
          examples:
            internal: { value: { error: internal_error } }

  schemas:
    Error:
      type: object
      description: |
        The one error shape. `error` is always present and is human-readable copy — usually
        Mongolian, occasionally English on developer-facing validation. `code` is present
        wherever an integrator is expected to branch, and is the only field you should branch
        on. Some errors carry extra fields alongside (`remaining`, `title`, `required_scope`,
        `field`), which is why this object is open.
      required: [error]
      properties:
        error: { type: string }
        code: { $ref: '#/components/schemas/ErrorCode' }
        remaining:
          type: integer
          description: On `out_of_stock` / `insufficient_stock` — how many units are actually left.
        title:
          type: string
          description: On `out_of_stock` / `insufficient_stock` — the product that blocked the order.
        required_scope:
          type: string
          description: On `insufficient_scope` — the permission the route needs.
        field:
          type: string
          description: On `validation_error` — the field that was refused.
      additionalProperties: true

    ErrorCode:
      type: string
      description: |
        The stable machine-readable error vocabulary. Additive: new values appear, existing
        values are never renamed or repurposed. Treat an unrecognised value as a generic
        failure of its HTTP status class rather than crashing.
      enum:
        - out_of_stock
        - insufficient_stock
        - payments_not_ready
        - payable_closed
        - rate_limited
        - unauthorized
        - invalid_api_key
        - insufficient_scope
        - api_key_scope_invalid
        - api_key_limit_reached
        - webhook_url_invalid
        - webhook_events_invalid
        - webhook_limit_reached
        - not_found
        - validation_error
        - idempotency_key_invalid
        - idempotency_in_progress
        - idempotency_key_reuse
        - endpoint_sunset
        - invalid_signature
        - connector_required
        - connector_not_linked

    ConnectorError:
      type: object
      description: The connector surface answers with the code in the `error` field itself — there is no separate message.
      required: [error]
      properties:
        error: { type: string }

    Product:
      type: object
      required: [id, title, price_mnt, status, images, attributes, created_at]
      properties:
        id: { type: string, format: uuid }
        slug: { type: string, description: Empty when the product has no custom handle. }
        title: { type: string }
        description: { type: string, description: Sanitised HTML. }
        product_type:
          type: string
          enum: [physical, digital, course, service]
        price_mnt: { type: integer, description: The regular price in tögrög. }
        cargo_fee_mnt: { type: integer, description: Per-product shipping surcharge, added to the order total. }
        category_id: { type: [string, 'null'], format: uuid }
        attributes:
          type: object
          additionalProperties: true
        images:
          type: array
          items: { type: string, format: uri }
        status:
          type: string
          enum: [active, draft]
          description: Drafts are visible only to a caller with `product:write`.
        discount_percent: { type: integer, minimum: 0, maximum: 90 }
        discount_amount_mnt: { type: integer, description: Flat discount. A product uses a percentage **or** an amount, never both. }
        discount_ends_at: { type: string, format: date-time }
        sale_price_mnt:
          type: [integer, 'null']
          description: |
            **The only reliable "is it on sale" signal.** Non-null exactly while a sale window
            is open; the discount fields alone do not tell you that. Use it as the price when
            present.
        sold_out: { type: boolean, description: Every active variant tracks stock and all are at zero. }
        stock_total: { type: [integer, 'null'], description: Remaining units summed across variants; null when stock is not tracked. }
        recent_paid_count:
          type: integer
          description: Buyers in the last seven days. Only ever ≥ 2 — a single buyer is never exposed. Absent when there is nothing to say.
        ebarimt_classification_code: { type: string }
        ebarimt_measure_unit: { type: string }
        ebarimt_tax_type: { type: string }
        ebarimt_tax_product_code: { type: string }
        ebarimt_city_tax: { type: boolean }
        ebarimt_barcode: { type: string }
        featured: { type: boolean }
        featured_position: { type: integer }
        created_at: { type: string, format: date-time }

    Variant:
      type: object
      required: [id, product_id, sku, price_mnt, stock_qty, position, is_default, status, version]
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        parent_id: { type: string, format: uuid }
        sku:
          type: string
          description: |
            Unique within the shop. A variant created without one is given a generated
            `SKU-<32 hex>`; treat that shape as "no SKU" rather than showing it to a person.
        barcode: { type: [string, 'null'] }
        price_mnt: { type: integer }
        cost_mnt:
          type: [integer, 'null']
          description: The merchant's buying price. **Never returned to an API key or an anonymous caller** — console sessions with `product:write` only.
        weight_g: { type: [integer, 'null'] }
        image_index: { type: [integer, 'null'], description: Index into the product's `images`. }
        stock_qty:
          type: [integer, 'null']
          description: "`null` = stock is not tracked (effectively unlimited). `0` = sold out."
        position: { type: integer }
        is_default: { type: boolean }
        status: { type: string, enum: [active, archived] }
        version: { type: integer, description: Optimistic-locking counter; send it back on a patch. }
        option_value_ids:
          type: array
          description: Which option values this variant represents.
          items: { type: string, format: uuid }

    VariantPatch:
      type: object
      description: |
        For an API key, only the five fields below may appear and omitted ones are left alone.
        A console session replaces the variant wholesale and may also send `cost_mnt`,
        `weight_g`, `image_index`, `position` and `status`.
      properties:
        sku: { type: string }
        barcode: { type: [string, 'null'] }
        price_mnt: { type: integer, minimum: 0 }
        stock_qty: { type: [integer, 'null'], minimum: 0 }
        version: { type: integer, description: The version you last read. Omit to skip the concurrency check. }

    ProductOption:
      type: object
      required: [id, name, position]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, description: 'The axis, e.g. "Өнгө".' }
        position: { type: integer }
        values:
          type: array
          items:
            type: object
            required: [id, value, position]
            properties:
              id: { type: string, format: uuid }
              value: { type: string }
              position: { type: integer }
              swatch: { type: string, description: A colour for rendering, when the axis is a colour. }
              image_index: { type: [integer, 'null'] }

    ShopCategory:
      type: object
      required: [id, name, slug, position, product_count]
      properties:
        id: { type: string, format: uuid }
        parent_id: { type: [string, 'null'], format: uuid }
        name: { type: string }
        slug: { type: string }
        position: { type: integer }
        image_url: { type: [string, 'null'], format: uri }
        source_category_id:
          type: [string, 'null']
          format: uuid
          description: The standard-taxonomy node this category is mapped to. Unmapped categories never appear on platform-wide discovery pages.
        source_category_name: { type: string, description: Display name of that node; empty when unmapped. }
        product_count: { type: integer }

    Bundle:
      type: object
      required: [id, title, price_mnt, regular_total_mnt, available, items]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        description: { type: string, description: Sanitised HTML. }
        image_url: { type: string }
        price_mnt: { type: integer, description: The bundle price — never more than the sum of its parts. }
        regular_total_mnt: { type: integer, description: What the components would cost separately. }
        available: { type: boolean, description: False when any component is unavailable. }
        items:
          type: array
          items:
            type: object
            required: [product_id, qty, title, unit_price_mnt, available]
            properties:
              product_id: { type: string, format: uuid }
              variant_id: { type: string, format: uuid }
              qty: { type: integer }
              title: { type: string }
              unit_price_mnt: { type: integer, description: Sale-aware, resolved server-side. }
              available: { type: boolean }

    Suggestion:
      type: object
      required: [id, title]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        shop_slug: { type: string, description: Only on the cross-shop variant of this endpoint. }
        shop_name: { type: string, description: Only on the cross-shop variant of this endpoint. }

    ReviewList:
      type: object
      required: [average, count, reviews]
      properties:
        average: { type: number, description: Mean rating. }
        count: { type: integer }
        reviews:
          type: array
          items:
            type: object
            required: [id, rating, verified, created_at]
            properties:
              id: { type: string, format: uuid }
              rating: { type: integer, minimum: 1, maximum: 5 }
              comment: { type: string }
              verified: { type: boolean, description: The reviewer actually bought it. }
              created_at: { type: string, format: date-time }

    CheckoutRequest:
      type: object
      description: At least one of `items` or `bundles` must be non-empty.
      properties:
        buyer_phone: { type: string }
        buyer_name: { type: string }
        customer_tin:
          type: string
          description: |
            The buyer organisation's ТТД for a B2B fiscal receipt — 11 to 14 digits. A 7-digit
            registration number is **not** valid here; resolve it with
            `GET /shop/ebarimt/tin-lookup` first. Setting this clears `consumer_register`.
        consumer_register:
          type: string
          description: The buyer's citizen register (two Cyrillic letters + eight digits) for a personal fiscal receipt.
        items:
          type: array
          maxItems: 100
          items:
            type: object
            required: [product_id, qty]
            properties:
              product_id: { type: string, format: uuid }
              variant_id: { type: string, format: uuid, description: Omit to use the product's default variant. }
              qty: { type: integer, minimum: 1, maximum: 10000 }
        bundles:
          type: array
          maxItems: 20
          description: Bundles are expanded into order lines server-side; the saving lands in `discount_mnt`.
          items:
            type: object
            required: [bundle_id, qty]
            properties:
              bundle_id: { type: string, format: uuid }
              qty: { type: integer, minimum: 1, maximum: 100 }
        coupon_code: { type: string }
        delivery_zone: { $ref: '#/components/schemas/DeliveryZone' }
        delivery_address: { type: string, maxLength: 500 }
        meta_fbp: { type: string, maxLength: 256, description: Meta attribution cookie, stored only with consent. }
        meta_fbc: { type: string, maxLength: 256, description: Meta attribution cookie, stored only with consent. }
        meta_event_id: { type: string, maxLength: 128 }
        meta_consent: { type: boolean }

    Order:
      type: object
      required: [id, total_mnt, discount_mnt, status, created_at]
      properties:
        id: { type: string, format: uuid, description: Also the receipt token — unguessable, and enough on its own to poll the status. }
        total_mnt: { type: integer }
        discount_mnt: { type: integer, description: Coupon and bundle savings combined. }
        status: { $ref: '#/components/schemas/OrderStatus' }
        invoice_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        paid_at:
          type: string
          format: date-time
          description: When the rail settled it. Present only once paid — key your own records on this, not on when your poll noticed.

    OrderStatus:
      type: string
      enum: [pending, paid, fulfilled, cancelled, expired]
      description: |
        `pending` → `paid` → `fulfilled` is the happy path. An unpaid order becomes `expired`
        when its invoice window closes — but a late payment can still move it to `paid`
        afterwards, so `expired` is not final.

    DeliveryZone:
      type: string
      enum: [UB, RURAL]
      description: Ulaanbaatar or the countryside — the two delivery-fee bands.

    Invoice:
      type: object
      required: [id, amount_mnt, status, rail]
      properties:
        id: { type: string, format: uuid }
        amount_mnt: { type: integer }
        status: { type: string }
        rail: { type: string, description: Which payment rail opened it. }
        qr_data: { type: string, description: Present when the rail returned a QR payload. }
        checkout_url:
          type: string
          format: uri
          description: |
            The hosted payment page. Send the payer here. Returning from it is **not** proof of
            payment — confirm with `GET /shop/checkout/{id}`.

    OrderRow:
      type: object
      description: One row of the merchant's order list.
      required: [id, status, total_mnt, discount_mnt, item_count, created_at]
      properties:
        id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OrderStatus' }
        total_mnt: { type: integer }
        discount_mnt: { type: integer }
        buyer_name: { type: string }
        buyer_phone: { type: string }
        delivery_zone: { $ref: '#/components/schemas/DeliveryZone' }
        item_count: { type: integer }
        items_summary: { type: string, description: A short human summary of the lines. }
        ebarimt_ddtd: { type: string, description: The fiscal receipt number, once issued. }
        created_at: { type: string, format: date-time }
        src_source: { type: string, description: First-touch traffic source; empty means direct. }
        source_label: { type: string, description: Mongolian display name for `src_source`. }

    OrderDetail:
      type: object
      required: [id, status, total_mnt, subtotal_mnt, discount_mnt, delivery_fee_mnt, cargo_fee_mnt, created_at, items]
      properties:
        id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OrderStatus' }
        subtotal_mnt: { type: integer }
        discount_mnt: { type: integer }
        delivery_fee_mnt: { type: integer }
        cargo_fee_mnt: { type: integer }
        total_mnt: { type: integer, description: subtotal − discount + delivery + cargo. }
        buyer_name: { type: string }
        buyer_phone: { type: string }
        consumer_register: { type: string }
        customer_tin: { type: string }
        delivery_zone: { $ref: '#/components/schemas/DeliveryZone' }
        delivery_address: { type: string }
        ebarimt_ddtd: { type: string }
        ebarimt_issued_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        src_source: { type: string }
        source_label: { type: string }
        items:
          type: array
          items:
            type: object
            required: [title, qty, unit_price_mnt]
            properties:
              title: { type: string, description: The product name, without the option labels. }
              variant: { type: string, description: 'The option labels, e.g. "Улаан / M".' }
              qty: { type: integer }
              unit_price_mnt: { type: integer, description: The price actually charged, sale included. }

    OrderExportRow:
      type: object
      required: [id, status, total_mnt, created_at]
      properties:
        id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OrderStatus' }
        buyer_name: { type: string }
        buyer_phone: { type: string }
        item_count: { type: integer }
        items_summary: { type: string }
        subtotal_mnt: { type: integer }
        discount_mnt: { type: integer }
        delivery_fee_mnt: { type: integer }
        cargo_fee_mnt: { type: integer }
        total_mnt: { type: integer }
        delivery_zone: { $ref: '#/components/schemas/DeliveryZone' }
        delivery_address: { type: string }
        customer_tin: { type: string }
        consumer_register: { type: string }
        ebarimt_ddtd: { type: string }
        created_at: { type: string, format: date-time }
        src_source: { type: string }
        source_label: { type: string }

    BuyerOrder:
      type: object
      description: The buyer's own view of an order — what a receipt page renders.
      required: [id, shop_slug, shop_name, status, total_mnt, discount_mnt, delivery_fee_mnt, cargo_fee_mnt, created_at, items]
      properties:
        id: { type: string, format: uuid }
        shop_slug: { type: string }
        shop_name: { type: string }
        status: { $ref: '#/components/schemas/OrderStatus' }
        total_mnt: { type: integer }
        discount_mnt: { type: integer }
        delivery_fee_mnt: { type: integer }
        cargo_fee_mnt: { type: integer }
        delivery_zone: { $ref: '#/components/schemas/DeliveryZone' }
        delivery_address: { type: string }
        created_at: { type: string, format: date-time }
        paid_at: { type: string, format: date-time }
        ebarimt_ddtd: { type: string }
        ebarimt_issued_at: { type: string, format: date-time }
        checkout_url:
          type: string
          format: uri
          description: Present only while the order is still payable — the payer can resume here.
        items:
          type: array
          items:
            type: object
            required: [title, qty, unit_price_mnt]
            properties:
              title: { type: string }
              qty: { type: integer }
              unit_price_mnt: { type: integer }

    CouponQuoteRequest:
      type: object
      required: [subtotal_mnt]
      properties:
        code: { type: string, description: Empty to preview the best automatic discount instead. }
        subtotal_mnt: { type: integer, minimum: 1 }
        lines:
          type: array
          description: The cart lines, so product- and category-restricted coupons can be judged.
          items:
            type: object
            properties:
              product_id: { type: string, format: uuid }
              variant_id: { type: string, format: uuid }
              qty: { type: integer }
              unit_price_mnt: { type: integer }

    CouponQuote:
      type: object
      required: [discount_mnt, total_mnt]
      properties:
        discount_mnt: { type: integer }
        total_mnt: { type: integer, description: subtotal − discount. Delivery is added later, at checkout. }
        code: { type: string, description: Echoed when a code was quoted. }
        name: { type: string, description: The campaign name, on an automatic discount. }
        type: { type: string, description: How the discount is computed. }
        auto: { type: boolean, description: True when this is the best automatic discount rather than a named code. }

    PublicPayable:
      type: object
      description: A payment link as the payer sees it.
      required: [id, kind, kind_label, title, amount_mnt, status, status_label, payable, fiscal, shop_name, lines]
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, description: What kind of payable this is (invoice, ticket, course fee, donation, …). }
        kind_label: { type: string, description: Mongolian display name. }
        title: { type: string }
        note: { type: string, description: Sanitised HTML. }
        amount_mnt: { type: integer }
        status: { type: string, enum: [open, paid, cancelled, expired] }
        status_label: { type: string }
        payable: { type: boolean, description: Whether the «Төлөх» action should be offered. }
        fiscal: { type: boolean, description: Whether a fiscal receipt will be issued on payment. }
        expires_at: { type: string, format: date-time }
        paid_at: { type: string, format: date-time }
        shop_name: { type: string }
        shop_phone: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              title: { type: string }
              qty: { type: integer }
              unit_price_mnt: { type: integer }
              total_mnt: { type: integer }

    PayableStatus:
      type: object
      required: [id, status, status_label, amount_mnt]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [open, paid, cancelled, expired] }
        status_label: { type: string }
        amount_mnt: { type: integer }
        paid_at: { type: [string, 'null'], format: date-time }

    PayableCheckoutRequest:
      type: object
      required: [payer_name, payer_phone]
      properties:
        payer_name: { type: string, maxLength: 120 }
        payer_phone: { type: string, maxLength: 32 }
        payer_email: { type: string, format: email, maxLength: 200 }
        customer_tin: { type: string, description: 11–14 digits, for a B2B fiscal receipt. }
        consumer_register: { type: string, description: The payer's citizen register, for a personal fiscal receipt. }

    KeyScope:
      type: string
      description: |
        The closed set of permissions an API key may carry. Permissions that administer
        people or credentials (`staff:manage`, `role:manage`, `integration:manage`) are
        deliberately absent: a credential does not administer credentials.
      enum:
        - "product:read"
        - "product:write"
        - "product:delete"
        - "order:read"
        - "order:write"

    APIKey:
      type: object
      required: [id, name, key_prefix, scopes, scope_labels, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        key_prefix: { type: string, description: 'The leading characters of the key, for recognising it in a list — e.g. "osk_live_ab1".' }
        scopes:
          type: array
          items: { $ref: '#/components/schemas/KeyScope' }
        scope_labels:
          type: array
          description: Mongolian display names, positionally matching `scopes`.
          items: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, 'null'], format: date-time, description: Stamped at most once a minute. }
        revoked_at: { type: [string, 'null'], format: date-time, description: Non-null means the key is dead. }

    WebhookEventType:
      type: string
      description: The subscribable events. `ping` is excluded — it can only be produced by the console's test button.
      enum:
        - order.created
        - order.paid
        - order.expired
        - order.fulfilled
        - order.unfulfilled
        - product.created
        - product.updated
        - product.deleted

    WebhookEventTypeOrPing:
      description: A subscribable event, or the console's `ping`.
      oneOf:
        - $ref: '#/components/schemas/WebhookEventType'
        - type: string
          const: ping

    WebhookEndpoint:
      type: object
      required: [id, url, events, event_labels, status, status_label, failure_streak, created_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        description: { type: string }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEventType' }
        event_labels:
          type: array
          description: Mongolian display names, positionally matching `events`.
          items: { type: string }
        status:
          type: string
          enum: [active, disabled, auto_disabled]
          description: "`auto_disabled` is the platform switching the endpoint off after a `410 Gone` or 50 consecutive failures. The merchant re-enables it from the console."
        status_label: { type: string }
        failure_streak: { type: integer, description: Consecutive failed deliveries; reset by any success. }
        last_delivery_at: { type: [string, 'null'], format: date-time }
        last_status_code: { type: [integer, 'null'] }
        secret_prev_expires_at:
          type: [string, 'null']
          format: date-time
          description: Present only while a rotation overlap is running — until then deliveries carry two signatures.
        created_at: { type: string, format: date-time }

    DeliveryStatus:
      type: string
      enum: [pending, delivering, delivered, failed, dead]
      description: "`failed` will be retried. `dead` is final: the attempts ran out, or the receiver answered something no retry can fix."

    WebhookDelivery:
      type: object
      required: [id, event_type, event_label, status, status_label, attempt, created_at, payload]
      properties:
        id: { type: string, format: uuid, description: Sent to the receiver as `X-OpenShop-Delivery-Id`. }
        event_type: { $ref: '#/components/schemas/WebhookEventTypeOrPing' }
        event_label: { type: string }
        status: { $ref: '#/components/schemas/DeliveryStatus' }
        status_label: { type: string }
        attempt: { type: integer }
        next_attempt_at: { type: [string, 'null'], format: date-time, description: Present while the delivery is still pending or being retried. }
        last_status_code: { type: [integer, 'null'] }
        last_error: { type: string, description: A short error class — never the receiver's response body. }
        delivered_at: { type: [string, 'null'], format: date-time }
        replay_of: { type: [string, 'null'], format: uuid, description: Set when this delivery is a replay of an earlier one. }
        created_at: { type: string, format: date-time }
        payload:
          $ref: '#/components/schemas/WebhookPayload'

    WebhookPayload:
      type: object
      description: |
        The thin envelope we POST to a merchant endpoint. It carries identifiers, status and
        money — **never** a name, phone, address or email. Fetch the detail with your API key.
      required: [id, type, created_at, shop, data]
      properties:
        id:
          type: string
          format: uuid
          description: The event id. Stable across retries and echoed in `X-OpenShop-Event-Id` — deduplicate on it.
        type: { $ref: '#/components/schemas/WebhookEventTypeOrPing' }
        created_at:
          type: string
          format: date-time
          description: When the delivery was queued. Informational — your own clock decides ordering.
        shop:
          type: object
          required: [slug]
          properties:
            slug: { type: string }
        data:
          description: Depends on `type`.
          oneOf:
            - $ref: '#/components/schemas/WebhookOrderData'
            - $ref: '#/components/schemas/WebhookProductData'
            - $ref: '#/components/schemas/WebhookPingData'

    WebhookOrderData:
      type: object
      description: The `data` of every `order.*` event.
      required: [order_id, status, total_mnt, discount_mnt]
      properties:
        order_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/OrderStatus' }
        total_mnt: { type: integer }
        discount_mnt: { type: integer }
        paid_at: { type: string, format: date-time, description: Present once the order has been paid. }

    WebhookProductData:
      type: object
      description: The `data` of every `product.*` event.
      required: [product_id]
      properties:
        product_id: { type: string, format: uuid }
        status: { type: string }
        updated_at: { type: string }

    WebhookPingData:
      type: object
      description: The `data` of a console test.
      properties:
        message: { type: string, const: OpenShop webhook test }

    ConnectorTenant:
      type: object
      required: [tenant_id, tenant_slug, tenant_name, status, eligible]
      properties:
        tenant_id: { type: string, format: uuid }
        tenant_slug: { type: string }
        tenant_name: { type: string }
        status: { type: string }
        eligible: { type: boolean, description: Whether this shop can be paired right now. }

    ConnectorLinkRequest:
      type: object
      required: [link_id, workspace_id]
      description: Name the shop with either `tenant_id` or `tenant_slug`.
      properties:
        link_id: { type: string, format: uuid, description: The connector's own id for this pairing. Replaying it is idempotent. }
        tenant_id: { type: string, format: uuid }
        tenant_slug: { type: string }
        workspace_id: { type: string, format: uuid }
        workspace_name: { type: string }
        linked_by:
          type: string
          enum: [buildry_admin, openshop_admin]
          description: Which side's administrator performed the pairing. Defaults to `buildry_admin`.
        actor: { type: string, description: Who performed it, for the audit trail. }

    ConnectorLink:
      type: object
      required: [link_id, tenant_id, workspace_id, status, linked_by]
      properties:
        link_id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        tenant_slug: { type: string }
        tenant_name: { type: string }
        workspace_id: { type: string, format: uuid }
        workspace_name: { type: string }
        status:
          type: string
          enum: [active, unlinked]
          description: Rows are never deleted — unlinking sets `unlinked` and stamps `unlinked_at`.
        linked_by: { type: string, enum: [buildry_admin, openshop_admin] }
        actor: { type: string }
        linked_at: { type: string, format: date-time }
        unlinked_at: { type: [string, 'null'], format: date-time }
