openapi: 3.1.0

info:
  title: cheap-keys Supplier API
  version: 1.1.0
  summary: >-
    Supplier-facing REST API for cheap-keys — catalog, pricing simulation, offers,
    inventory, orders read model, balance & ledger, webhook management, and the sandbox
    order simulator.
  contact:
    name: cheap-keys API support
    email: "api@pay.mmokick.com"
    url: "https://pay.mmokick.com/developers"
  description: |
    The complete supplier-facing REST API for **cheap-keys**. This document is the
    machine-readable source of truth for everything the platform hosts and suppliers call.
    The two contracts *suppliers* host and *we* call are published separately:
    `declared-stock-supplier-contract.yaml` (reservation → order → items fulfilment) and
    `topup-contract.yaml` (validate → execute).

    ## Authentication

    `Authorization: Bearer <token>` on every request. No other scheme exists. Tokens are
    `ck_live_` (production) or `ck_test_` (sandbox) + 40 random base62 characters, created
    self-service in the supplier portal (2FA required), SHA-256-hashed at rest, **shown once**.
    Up to **25 tokens per environment** per supplier. Tokens never expire; revoke instantly in
    the portal. Using a `ck_test_` token against production (or `ck_live_` against sandbox)
    returns `401` / code `wrong_environment` — never a silent fallback.

    Scopes are assigned at token creation (default: all). A token missing the required scope
    gets `403` / `insufficient_scope`. A request for another supplier's resource returns
    `404` / `not_found` (no existence disclosure). Each operation below declares its required
    scope in `x-required-scope`.

    Tokens are for server-to-server use only. The API sends no CORS headers; browser use is
    unsupported by design.

    ## Conventions

    - **JSON only.** Requests with a body require `Content-Type: application/json`
      (else `415`). Responses are `application/json; charset=utf-8`; errors are
      `application/problem+json`.
    - **IDs** are ULIDs (26-char Crockford base32). Public order numbers are `CK-` + the
      order ULID. Path parameters take the bare ULID.
    - **Money** is always `{"amount": <integer minor units>, "currency": "<ISO 4217>"}`.
      Supplier API v1 is **EUR-only**; any non-EUR currency in a request is
      `422` / `validation_failed`. Never floats, never strings.
    - **Time** is UTC ISO-8601 with `Z`; field names end `_at`.
    - Every response carries `X-Request-Id` (also embedded in error bodies as `trace_id`).
    - **Body limit** 1 MB (`413` / `payload_too_large`).
    - **Unknown fields in requests are rejected** (`422`). Unknown fields in responses must
      be tolerated by clients — we add fields without a version bump.

    ## Idempotency

    The `Idempotency-Key` header is honored on all mutating POST endpoints (marked with the
    `Idempotency-Key` parameter below). PUT/PATCH/DELETE are inherently idempotent and do not
    use the header.

    - Key: any string, 1–200 chars. Recommended: a UUID/ULID per logical operation.
    - Scope: `(token, method, path, key)`. Retention: **24 hours**.
    - Replay with same key + identical body → the original response, status included, plus
      header `Idempotent-Replay: true`.
    - Same key + different body → `409` / `idempotency_conflict`.
    - Same key while the first request is still executing → `409` / `idempotency_in_flight`
      with `Retry-After: 2`.
    - `POST /pricing/simulate` is read-only; the header is accepted but ignored there.

    ## Pagination

    Cursor-based on every list endpoint: `?limit=` (default 25, max 100) and `?cursor=`
    (opaque string from a previous response). Responses are
    `{"data": [...], "next_cursor": "..."}`; `next_cursor` is `null` on the last page.
    Cursors are opaque and expire after 24 h (`400` / `invalid_cursor` after).

    ## Rate limits (published numbers)

    Enforced per token, rolling 60-second windows. Every response from a limiter that ran —
    success or error — carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
    `X-RateLimit-Reset` (unix seconds) with the real budget for that window; these are never
    fabricated on an error rejected before any limiter counted it. `429` adds `Retry-After`
    and problem code `rate_limited`. Each operation declares its class in `x-rate-limit-class`:

    | Class | Applies to | Limit per token / min |
    |---|---|---|
    | reads | every GET | 600 |
    | writes | mutating endpoints not otherwise classed | 120 |
    | sync | `PUT …/price`, `PUT …/stock` | 300 |
    | bulk | `POST …/inventory` (≤1,000 items each) | 12 |
    | simulate | `POST /pricing/simulate` | 120 |
    | test | webhook test-fire, fulfilment check, sandbox order simulator | 6 |

    Additional ceilings: **1,200 requests/min per supplier** across all its tokens, and
    **30 requests/min per IP** for unauthenticated requests (missing/garbage token), enforced
    before token verification; a well-formed bearer token is governed by the per-token limits
    instead. Limits are identical in sandbox.
    Raises: api@pay.mmokick.com — recorded per supplier and reflected in `GET /whoami`.

    ## Errors — RFC 9457 problem+json

    Every non-2xx response is `application/problem+json` with a stable machine `code`
    (the field integrations switch on; `title`/`detail` texts may change, codes never do).
    `type` is always `https://pay.mmokick.com/developers/errors#<code>`. The full catalog is
    the `ErrorCode` schema. Codes are **append-only**: new codes may appear; existing codes
    never change meaning or HTTP status.

    ## Versioning

    URL major version (`/v1`). Additive changes happen in-place and are recorded in the
    changelog below (and machine-readably in `x-changelog`). Breaking changes require `/v2`
    with a 12-month overlap of `/v1`; deprecations are announced via the changelog and a
    `Deprecation: true` + `Sunset: <http-date>` header pair on affected endpoints.

    ## Webhooks

    Signed event push to supplier-registered endpoints — see the `webhooks` section of this
    document for every event type and payload. Signature scheme (also used for our calls to
    your fulfilment endpoints):

    ```
    signed_payload = <X-CK-Timestamp value> + "." + <raw request body bytes>
    X-CK-Signature: v1=hex( HMAC-SHA256( secret, signed_payload ) )
    ```

    `X-CK-Timestamp` is unix **milliseconds**; reject when `|now − timestamp| > 5 minutes`.
    Verify against the raw body with a constant-time comparison. Official test vector:

    ```
    secret:         whsec_9a41c1f39b6a4f16bd1e2f7c8d3a5e70
    timestamp:      1767225600000
    body:           {"hello":"cheap-keys"}
    signed_payload: 1767225600000.{"hello":"cheap-keys"}
    signature:      8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a
    header:         X-CK-Signature: v1=8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a
    ```

    Delivery: success = any `2xx` within 10 s. Retry schedule: +0, +1 m, +5 m, +30 m, +2 h,
    +8 h, +24 h (7 attempts). 30 consecutive failed attempts auto-disable the endpoint
    (`webhook.disabled` event + portal alert + email). A `410 Gone` response disables the
    endpoint immediately. Ordering is not guaranteed — dedupe on envelope `id`. Egress IPs
    are not published; authenticate deliveries by signature only.

    ## Changelog

    ### 2026-07-11 — 1.0.0

    - Initial public release of `/v1`: token introspection, catalog (products, categories),
      machine-readable fee schedule, pricing simulation, offers (uploaded + declared stock
      modes, fast price/stock lanes, fulfilment healthcheck + secret rotation), inventory
      bulk upload/list/invalidate, orders read model, balance + append-only ledger, webhook
      endpoint CRUD + test-fire + delivery log + resend, sandbox order simulator.
    - Published rate-limit classes (reads 600, writes 120, sync 300, bulk 12, simulate 120,
      test 6 per token/min; 1,200/min per supplier).
    - Published error catalog (RFC 9457, stable `code` field) and webhook event catalog
      (order.created, order.paid, order.expired, order.refunded, order.disputed,
      offer.deactivated, offer.stock_low, stock.invalidated, webhook.disabled).
    - Fee schedule v1: commission 850 bps, min fee €0.05/item, no listing / price-change /
      payment / unfulfilled-sale fees, settlement 7 days after delivery.

x-changelog:
  # Entry shape is binding (07-developer-docs.md §7): {date, version, changes:[{type, summary}]},
  # type ∈ added | changed | deprecated | fixed; info.version equals the newest entry's version.
  - date: "2026-07-12"
    version: "1.1.0"
    changes:
      - type: added
        summary: "GET /offers filters: external_ref, status, product_id, updated_since."
      - type: added
        summary: Webhook event order.expired — a terminal event fired when an unpaid order is swept to expired.
  - date: "2026-07-11"
    version: "1.0.0"
    changes:
      - type: added
        summary: Initial public release of /v1 (all endpoints in this document).
      - type: added
        summary: Published rate-limit classes and per-supplier ceiling.
      - type: added
        summary: Published RFC 9457 error catalog with stable append-only codes.
      - type: added
        summary: Published webhook event catalog and HMAC signature scheme v1 with official test vector.
      - type: added
        summary: Fee schedule v1 (850 bps commission, €0.05 min fee, 7-day settlement).

servers:
  # Sandbox listed FIRST deliberately — Scalar's try-it console defaults to the first entry,
  # which must be the safe target (07-developer-docs.md §3.1). Both entries are bare tokens
  # resolved at serve time from config('docs.*') (APP_URL / DOCS_*): https://sandbox-pay.mmokick.com/api/v1
  # already encodes the dedicated-vhost-vs-`/sandbox`-path-prefix decision, so the served
  # list is always exactly [real sandbox, production] — no baked host, no dead fallback.
  - url: "https://sandbox-pay.mmokick.com/api/v1"
    description: >-
      Sandbox — ck_test_ tokens. Separate database, deterministic fixtures, identical rate
      limits. Settlement clock shortened to 10 minutes.
  - url: "https://pay.mmokick.com/api/v1"
    description: Production — ck_live_ tokens. Behind Cloudflare. HTTPS only (TLS 1.2+).

security:
  - bearerToken: []

tags:
  - name: Introspection
    description: Token and environment introspection — your integration smoke test.
  - name: Catalog
    description: >-
      Platform-owned canonical products and categories. Suppliers attach offers to canonical
      products — never free-text listings. New products are requested through the portal.
  - name: Pricing
    description: Machine-readable fee schedule and pricing simulation.
  - name: Offers
    description: >-
      Your offers on canonical products — one offer per (supplier, product). Price, stock
      mode, fulfilment config, health.
  - name: Inventory
    description: Pre-uploaded deliverables (uploaded stock mode). Encrypted at rest on arrival.
  - name: Orders
    description: >-
      Read model — every order containing at least one of your items, with only your items,
      and no buyer PII beyond buyer_country.
  - name: Balance
    description: Available + pending balance and the append-only ledger.
  - name: Webhooks
    description: Webhook endpoint CRUD, secret rotation, test-fire.
  - name: Webhook deliveries
    description: Searchable per-attempt delivery log (30-day retention) and resend.
  - name: Sandbox
    description: Sandbox-only helpers. Return 404 / not_found on production.

paths:

  /whoami:
    get:
      operationId: getWhoami
      tags: [Introspection]
      summary: Token introspection
      description: |
        Returns the calling token, its supplier, the environment, and the effective
        rate-limit numbers (reflecting any granted raises). Works with any valid token
        regardless of scope. Suspended suppliers get `403` / `supplier_suspended` on
        everything **except** this endpoint. Use this as your integration smoke test.
      x-required-scope: none (any valid token)
      x-rate-limit-class: reads
      responses:
        "200":
          description: The calling token and supplier.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WhoAmI" }
              examples:
                live-token:
                  summary: Production token with three scopes
                  value:
                    token:
                      id: 01JZV9AB2CD4EF6GH8JK0MN2PQ
                      name: repricer-bot
                      mode: live
                      scopes: [offers:read, offers:write, pricing:read]
                      created_at: "2026-06-02T09:14:00Z"
                      last_used_at: "2026-07-11T13:59:41Z"
                    supplier:
                      id: 01JZV8K2E4Q6S8A0C2E4G6J8KM
                      name: Keyforge Ltd
                      status: active
                    environment: production
                    rate_limits: { reads: 600, writes: 120, sync: 300, bulk: 12, simulate: 120, test: 6, ceilings: { per_supplier: 1200 } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /products:
    get:
      operationId: listProducts
      tags: [Catalog]
      summary: Browse/search the canonical catalog
      description: |
        Paginated catalog listing with filters. `market` on each product is supplier-facing
        market intelligence: the currently routed buyer price, count of competing active
        offers, and whether your offer currently wins routing. No competitor identities are
        exposed. Use `updated_since` for delta sync of catalog mirrors.
      x-required-scope: catalog:read
      x-rate-limit-class: reads
      parameters:
        - name: q
          in: query
          description: Full-text search on name.
          schema: { type: string, maxLength: 100 }
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/ProductType" }
        - name: platform
          in: query
          schema: { $ref: "#/components/schemas/Platform" }
        - name: region
          in: query
          schema: { $ref: "#/components/schemas/Region" }
        - name: category_id
          in: query
          description: Category subtree filter (includes descendants).
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: sellable
          in: query
          description: Only products accepting new offers.
          schema: { type: boolean, default: true }
        - name: updated_since
          in: query
          description: Delta sync — products updated at/after this instant.
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of products.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Product" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                steam-key-search:
                  summary: type=game_key&platform=steam&q=starforge&limit=2
                  value:
                    data:
                      - id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                        slug: starforge-odyssey-pc-steam-key-global
                        name: Starforge Odyssey (PC) — Steam Key — GLOBAL
                        type: game_key
                        platform: steam
                        region: global
                        allowed_countries: null
                        blocked_countries: [DE]
                        category_id: 01JZT2A1B2C3D4E5F6G7H8J9K0
                        attributes:
                          steam_app_id: "1289310"
                          edition: standard
                          works_on: [windows]
                        form_fields: []
                        status: sellable
                        market:
                          buyer_price: { amount: 2049, currency: EUR }
                          active_offers: 3
                          your_offer_selected: false
                        created_at: "2026-03-01T10:00:00Z"
                        updated_at: "2026-07-01T08:30:00Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /products/{product_id}:
    get:
      operationId: getProduct
      tags: [Catalog]
      summary: Product detail
      description: |
        Single product, same shape as the list. For `type: topup` products, `form_fields`
        declares the buyer inputs the top-up contract will send you. The platform validates
        buyer input against `required`/`pattern`/`max_length`/`options` **before** calling
        your top-up validate endpoint; you receive values keyed by `key`.
      x-required-scope: catalog:read
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/ProductId"
      responses:
        "200":
          description: The product.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Product" }
              examples:
                topup-product:
                  summary: A top-up product with declared form_fields
                  value:
                    id: 01JZT5B7XCDE2FGH4JK6MN8PQ2
                    slug: mmo-kick-gold-1000-top-up
                    name: MMO Kick Gold — 1,000 Gold (top-up)
                    type: topup
                    platform: other
                    region: global
                    allowed_countries: null
                    blocked_countries: null
                    category_id: 01JZT2A1B2C3D4E5F6G7H8J9K0
                    attributes: { game: mmo-kick, denomination: "1000" }
                    form_fields:
                      - key: user_id
                        label: Player ID
                        type: text
                        required: true
                        pattern: "^[0-9]{6,12}$"
                        max_length: 12
                        help_text: Settings → Profile → Player ID.
                        options: null
                      - key: server_id
                        label: Server
                        type: select
                        required: true
                        pattern: null
                        max_length: null
                        help_text: null
                        options:
                          - { value: "2001", label: EU West }
                          - { value: "2002", label: EU East }
                    status: sellable
                    market: { buyer_price: null, active_offers: 0, your_offer_selected: false }
                    created_at: "2026-05-10T12:00:00Z"
                    updated_at: "2026-05-10T12:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /categories:
    get:
      operationId: listCategories
      tags: [Catalog]
      summary: Category tree
      description: Flat paginated list with adjacency (`parent_id`); rebuild the tree client-side.
      x-required-scope: catalog:read
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of categories.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Category" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                roots:
                  value:
                    data:
                      - { id: 01JZT2A1B2C3D4E5F6G7H8J9K0, name: Games, slug: games, parent_id: null }
                      - { id: 01JZT2A2P4R6T8V0X2Z4B6D8F0, name: Gift Cards, slug: gift-cards, parent_id: null }
                    next_cursor: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /fees:
    get:
      operationId: getFees
      tags: [Pricing]
      summary: Machine-readable fee schedule
      description: |
        One commission, no micro-fees. v1 numbers: commission 8.5% (850 bps) per order item
        on gross sale price (rounded half-up), minimum fee €0.05 per item; **no** listing,
        price-change, payment, or unfulfilled-sale fees. Funds settle (pending → available)
        7 days after delivery. Fee changes ship with ≥30 days notice via changelog + email;
        `effective_from` dates every revision.
      x-required-scope: none — public, unauthenticated (spine differentiator 4)
      x-rate-limit-class: reads (30/min unauthenticated per-IP)
      security: []
      responses:
        "200":
          description: The current fee schedule.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FeeSchedule" }
              examples:
                v1:
                  value:
                    version: "2026-07-01"
                    effective_from: "2026-07-01T00:00:00Z"
                    currency: EUR
                    commission:
                      rate_bps: 850
                      min_fee: { amount: 5, currency: EUR }
                      applies: per order item, on gross sale price, rounded half-up
                    listing_fee: null
                    price_change_fee: null
                    payment_fees_charged_to_supplier: false
                    payment_fees_charged_to_buyer: false
                    settlement: { holding_period_days: 7, trigger: delivery }
                    payout:
                      methods: [sepa_bank_transfer, paypal]
                      minimum: { amount: 2000, currency: EUR }
                      fee: null
                    unfulfilled_sale_fee: null
                    docs_url: "https://pay.mmokick.com/developers/fees"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /pricing/simulate:
    post:
      operationId: simulatePricing
      tags: [Pricing]
      summary: Simulate fees, net payout, and routing outcome for a candidate price
      description: |
        Given a product and **either** a candidate `price` **or** a `target_net_payout`
        (both → `422` / `validation_failed`), returns exact fees, net payout, and whether
        you'd win routing. Reverse mode computes the smallest price whose net ≥ target.
        `buyer_price` always equals `price`: buyers pay all-in, our margin **is** the
        commission. `would_win_routing` assumes your offer is active and healthy and applies
        the routing rule (cheapest; tie-break delivery SLA then supplier age).

        Read-only and safe to hammer (120/min). `Idempotency-Key` is accepted but ignored.
        Commission math: `max(round_half_up(gross × 0.085), 5)` per item.
      x-required-scope: pricing:read
      x-rate-limit-class: simulate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PricingSimulationRequest" }
            examples:
              forward:
                summary: Price → fees + net
                value:
                  product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                  price: { amount: 1999, currency: EUR }
              reverse:
                summary: Target net → required price
                value:
                  product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                  target_net_payout: { amount: 1829, currency: EUR }
      responses:
        "200":
          description: Simulation result.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PricingSimulationResult" }
              examples:
                forward:
                  value:
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    price: { amount: 1999, currency: EUR }
                    buyer_price: { amount: 1999, currency: EUR }
                    commission:
                      rate_bps: 850
                      amount: { amount: 170, currency: EUR }
                      min_fee_applied: false
                    net_payout: { amount: 1829, currency: EUR }
                    settlement: { holding_period_days: 7 }
                    market:
                      current_buyer_price: { amount: 2049, currency: EUR }
                      would_win_routing: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers:
    get:
      operationId: listOffers
      tags: [Offers]
      summary: List your offers
      description: |
        Paginated list of all offers belonging to the calling supplier. Filters are
        optional and combinable: `external_ref` (your ERP key — exact match), `status`,
        `product_id`, and `updated_since` for delta sync of offer mirrors.
      x-required-scope: offers:read
      x-rate-limit-class: reads
      parameters:
        - name: external_ref
          in: query
          description: Exact match on your ERP key (`external_ref`).
          schema: { type: string, maxLength: 64 }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/OfferStatus" }
        - name: product_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: updated_since
          in: query
          description: Delta sync — offers updated at/after this instant.
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of offers.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Offer" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                one-declared-offer:
                  value:
                    data:
                      - id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                        product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                        external_ref: SKU-STARFORGE-GLOBAL
                        status: active
                        stock_mode: declared
                        price: { amount: 1999, currency: EUR }
                        delivery_time: instant
                        low_stock_threshold: 10
                        stock:
                          mode: declared
                          declared: 250
                          available: null
                          reserved: null
                          delivered: 8811
                        fulfilment:
                          base_url: https://api.keyforge.example/ck
                          secret_set: true
                          last_healthcheck_at: "2026-07-11T13:55:00Z"
                          last_healthcheck_ok: true
                        health: { score: 96, routing_eligible: true, cooldown_until: null }
                        created_at: "2026-06-05T11:20:00Z"
                        updated_at: "2026-07-11T13:58:12Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createOffer
      tags: [Offers]
      summary: Create an offer
      description: |
        One offer per (supplier, product) — duplicates return `409` / `offer_exists`.
        Offers are created in `status: draft`; activate explicitly.

        Rules: `price.amount` ≥ 10 (minimum sale €0.10) and ≤ 50,000,000. `external_ref`
        ≤ 64 chars, unique per supplier, echoed on every callback and webhook.
        `fulfilment.base_url` must be HTTPS, publicly resolvable, not a private/link-local
        IP, ≤ 2,048 chars. `stock_mode` is fixed at creation — to switch modes, archive and
        recreate. For `uploaded` offers omit `fulfilment`/`declared_stock` (present → `422`).
        `fulfilment` is required for `declared` offers and for offers on `topup` products.

        The `201` response includes — **once only** — the fulfilment signing secret
        (`fulfilment.secret`, prefix `cksec_`). Store it; it is never returned again
        (rotate to obtain a new one).
      x-required-scope: offers:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OfferCreateRequest" }
            examples:
              declared:
                summary: Declared-stock offer with fulfilment endpoint
                value:
                  product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                  stock_mode: declared
                  price: { amount: 1999, currency: EUR }
                  external_ref: SKU-STARFORGE-GLOBAL
                  low_stock_threshold: 10
                  fulfilment: { base_url: https://api.keyforge.example/ck }
                  declared_stock: 250
              uploaded:
                summary: Uploaded-stock offer (no fulfilment block)
                value:
                  product_id: 01SANDBX00000000000000PD02
                  stock_mode: uploaded
                  price: { amount: 989, currency: EUR }
                  external_ref: SKU-STEAM-GC-10
                  low_stock_threshold: 20
      responses:
        "201":
          description: >-
            Offer created (status draft). For declared/top-up offers the body carries the
            fulfilment signing secret — the only time it is shown.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
              examples:
                created-declared:
                  value:
                    id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    external_ref: SKU-STARFORGE-GLOBAL
                    status: draft
                    stock_mode: declared
                    price: { amount: 1999, currency: EUR }
                    delivery_time: instant
                    low_stock_threshold: 10
                    stock:
                      mode: declared
                      declared: 250
                      available: null
                      reserved: null
                      delivered: 0
                    fulfilment:
                      base_url: https://api.keyforge.example/ck
                      secret: cksec_7f2e9a41c1f3b6a4d1e8c05b3a5e7096
                      secret_set: true
                      last_healthcheck_at: null
                      last_healthcheck_ok: null
                    health: { score: 100, routing_eligible: false, cooldown_until: null }
                    created_at: "2026-06-05T11:20:00Z"
                    updated_at: "2026-06-05T11:20:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`offer_exists` (one offer per product — PATCH the existing offer), or an idempotency conflict."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                offer-exists:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#offer_exists"
                    title: Offer already exists
                    status: 409
                    detail: You already have an offer on this product. PATCH the existing offer instead.
                    code: offer_exists
                    trace_id: req_2b1c9d40e7f84a21
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422":
          description: "`validation_failed` or `product_not_sellable`."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                validation:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#validation_failed"
                    title: Validation failed
                    status: 422
                    detail: price.amount must be a positive integer.
                    code: validation_failed
                    errors: { "price.amount": ["must be a positive integer"] }
                    trace_id: req_9f3b2c61a8d44e0f
                not-sellable:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#product_not_sellable"
                    title: Product not sellable
                    status: 422
                    detail: This product is archived or paused for new offers.
                    code: product_not_sellable
                    trace_id: req_5d0a7e12c3b94f68
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}:
    get:
      operationId: getOffer
      tags: [Offers]
      summary: Offer detail (including stock and health)
      description: >-
        The full offer object. `health` is read-only: score 0–100 (routing requires ≥ 70),
        routing eligibility, and any active cooldown.
      x-required-scope: offers:read
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/OfferId"
      responses:
        "200":
          description: The offer.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
              examples:
                declared-active:
                  value:
                    id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    external_ref: SKU-STARFORGE-GLOBAL
                    status: active
                    stock_mode: declared
                    price: { amount: 1999, currency: EUR }
                    delivery_time: instant
                    low_stock_threshold: 10
                    stock:
                      mode: declared
                      declared: 250
                      available: null
                      reserved: null
                      delivered: 8811
                    fulfilment:
                      base_url: https://api.keyforge.example/ck
                      secret_set: true
                      last_healthcheck_at: "2026-07-11T13:55:00Z"
                      last_healthcheck_ok: true
                    health: { score: 96, routing_eligible: true, cooldown_until: null }
                    created_at: "2026-06-05T11:20:00Z"
                    updated_at: "2026-07-11T13:58:12Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateOffer
      tags: [Offers]
      summary: Update an offer
      description: |
        Partial update of `price`, `external_ref`, `low_stock_threshold`,
        `fulfilment.base_url`, `declared_stock`. Changing `fulfilment.base_url` on an
        **active** offer triggers an inline healthcheck against the new URL —
        `422` / `fulfilment_unreachable` on failure, and nothing is changed.
      x-required-scope: offers:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/OfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OfferUpdateRequest" }
            examples:
              reprice-and-threshold:
                value:
                  price: { amount: 1899, currency: EUR }
                  low_stock_threshold: 10
      responses:
        "200":
          description: The updated offer.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
              examples:
                updated:
                  value:
                    id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    external_ref: SKU-STARFORGE-GLOBAL
                    status: active
                    stock_mode: declared
                    price: { amount: 1899, currency: EUR }
                    delivery_time: instant
                    low_stock_threshold: 10
                    stock:
                      mode: declared
                      declared: 250
                      available: null
                      reserved: null
                      delivered: 8811
                    fulfilment:
                      base_url: https://api.keyforge.example/ck
                      secret_set: true
                      last_healthcheck_at: "2026-07-11T13:55:00Z"
                      last_healthcheck_ok: true
                    health: { score: 96, routing_eligible: true, cooldown_until: null }
                    created_at: "2026-06-05T11:20:00Z"
                    updated_at: "2026-07-11T14:05:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422":
          description: "`validation_failed` or `fulfilment_unreachable` (inline healthcheck against new URL failed)."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                unreachable:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#fulfilment_unreachable"
                    title: Fulfilment endpoint unreachable
                    status: 422
                    detail: "GET https://new.keyforge.example/ck/healthcheck: timeout after 5000ms."
                    code: fulfilment_unreachable
                    trace_id: req_77aa1b2c3d4e5f60
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: archiveOffer
      tags: [Offers]
      summary: Archive an offer (terminal)
      description: >-
        Archives the offer — a terminal state. Remaining `available` inventory items are
        auto-invalidated. In-flight orders complete normally.
      x-required-scope: offers:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/OfferId"
      responses:
        "200":
          description: The archived offer.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
              examples:
                archived:
                  value:
                    id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    external_ref: SKU-STARFORGE-GLOBAL
                    status: archived
                    stock_mode: declared
                    price: { amount: 1899, currency: EUR }
                    delivery_time: instant
                    low_stock_threshold: 10
                    stock:
                      mode: declared
                      declared: 0
                      available: null
                      reserved: null
                      delivered: 8811
                    fulfilment:
                      base_url: https://api.keyforge.example/ck
                      secret_set: true
                      last_healthcheck_at: "2026-07-11T13:55:00Z"
                      last_healthcheck_ok: true
                    health: { score: 96, routing_eligible: false, cooldown_until: null }
                    created_at: "2026-06-05T11:20:00Z"
                    updated_at: "2026-07-11T14:10:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/activate:
    post:
      operationId: activateOffer
      tags: [Offers]
      summary: Activate an offer
      description: |
        No request body. Preconditions: the product is `sellable`; for `declared`/top-up
        offers a **live healthcheck runs inline** — failure returns
        `422` / `fulfilment_unreachable` with the probe result in `detail`. Activating an
        `uploaded` offer with zero stock succeeds but the offer stays unroutable until stock
        arrives. Wrong state (e.g. `archived`) → `409` / `invalid_state`.
      x-required-scope: offers:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: "The offer, now `status: active`."
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
              examples:
                activated:
                  value:
                    id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    external_ref: SKU-STARFORGE-GLOBAL
                    status: active
                    stock_mode: declared
                    price: { amount: 1999, currency: EUR }
                    delivery_time: instant
                    low_stock_threshold: 10
                    stock:
                      mode: declared
                      declared: 250
                      available: null
                      reserved: null
                      delivered: 0
                    fulfilment:
                      base_url: https://api.keyforge.example/ck
                      secret_set: true
                      last_healthcheck_at: "2026-07-11T14:00:00Z"
                      last_healthcheck_ok: true
                    health: { score: 100, routing_eligible: true, cooldown_until: null }
                    created_at: "2026-06-05T11:20:00Z"
                    updated_at: "2026-07-11T14:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`invalid_state` — transition not allowed from the current status."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                invalid-state:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#invalid_state"
                    title: Invalid state
                    status: 409
                    detail: An archived offer cannot be activated.
                    code: invalid_state
                    trace_id: req_31f0aa92bb7c4d55
        "422":
          description: "`fulfilment_unreachable` (inline healthcheck failed) or `product_not_sellable`."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                unreachable:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#fulfilment_unreachable"
                    title: Fulfilment endpoint unreachable
                    status: 422
                    detail: "GET https://api.keyforge.example/ck/healthcheck: HTTP 503 after 5002ms."
                    code: fulfilment_unreachable
                    trace_id: req_8e21c4f6a9b04d13
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/deactivate:
    post:
      operationId: deactivateOffer
      tags: [Offers]
      summary: Deactivate an offer
      description: |
        No request body. The offer is immediately removed from routing; open reservations
        and orders still complete. Fires `offer.deactivated` (reason `supplier_request`) to
        your webhook endpoints.
      x-required-scope: offers:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: "The offer, now `status: paused`."
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Offer" }
              examples:
                deactivated:
                  value:
                    id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    external_ref: SKU-STARFORGE-GLOBAL
                    status: paused
                    stock_mode: declared
                    price: { amount: 1999, currency: EUR }
                    delivery_time: instant
                    low_stock_threshold: 10
                    stock:
                      mode: declared
                      declared: 250
                      available: null
                      reserved: null
                      delivered: 8811
                    fulfilment:
                      base_url: https://api.keyforge.example/ck
                      secret_set: true
                      last_healthcheck_at: "2026-07-11T13:55:00Z"
                      last_healthcheck_ok: true
                    health: { score: 96, routing_eligible: false, cooldown_until: null }
                    created_at: "2026-06-05T11:20:00Z"
                    updated_at: "2026-07-11T14:12:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/price:
    put:
      operationId: updateOfferPrice
      tags: [Offers]
      summary: Fast price update (repricer lane)
      description: |
        Repricer fast lane — 300/min (`sync` class), no price-change fees, ever.
        Inherently idempotent (PUT); no `Idempotency-Key`.
      x-required-scope: offers:write
      x-rate-limit-class: sync
      parameters:
        - $ref: "#/components/parameters/OfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PriceUpdateRequest" }
            examples:
              reprice:
                value: { price: { amount: 1899, currency: EUR } }
      responses:
        "200":
          description: New price confirmed.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PriceUpdateResult" }
              examples:
                repriced:
                  value:
                    offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    price: { amount: 1899, currency: EUR }
                    updated_at: "2026-07-11T14:01:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/stock:
    put:
      operationId: declareOfferStock
      tags: [Offers]
      summary: Declare stock count (declared mode)
      description: |
        Sets the self-declared stock count — a routing signal, not a promise (the
        reservation call is the truth). `0` makes the offer unroutable without deactivating
        it. On an `uploaded` offer → `409` / `stock_mode_mismatch`.
      x-required-scope: offers:write
      x-rate-limit-class: sync
      parameters:
        - $ref: "#/components/parameters/OfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StockDeclareRequest" }
            examples:
              declare:
                value: { declared_stock: 175 }
      responses:
        "200":
          description: New declared count confirmed.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StockDeclareResult" }
              examples:
                declared:
                  value:
                    offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    declared_stock: 175
                    updated_at: "2026-07-11T14:02:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`stock_mode_mismatch` — stock declaration on an uploaded offer."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                mismatch:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#stock_mode_mismatch"
                    title: Stock mode mismatch
                    status: 409
                    detail: This offer uses uploaded stock; declared counts do not apply.
                    code: stock_mode_mismatch
                    trace_id: req_c4d5e6f708192a3b
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/fulfilment/check:
    post:
      operationId: checkOfferFulfilment
      tags: [Offers]
      summary: Run a fulfilment healthcheck now
      description: |
        Runs `GET {base_url}/healthcheck` against the offer's configured fulfilment endpoint
        immediately and returns the probe result. A failed probe is still HTTP `200` here —
        the check ran; the *result* failed. Rate class `test` (6/min).
      x-required-scope: offers:write
      x-rate-limit-class: test
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Probe result (ok or failed).
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FulfilmentCheckResult" }
              examples:
                ok:
                  value:
                    status: ok
                    http_status: 200
                    latency_ms: 142
                    checked_at: "2026-07-11T14:02:10Z"
                failed:
                  value:
                    status: failed
                    http_status: 503
                    latency_ms: 5002
                    error: timeout after 5000ms
                    checked_at: "2026-07-11T14:02:10Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/fulfilment/rotate-secret:
    post:
      operationId: rotateFulfilmentSecret
      tags: [Offers]
      summary: Rotate the fulfilment callback secret
      description: |
        Returns a new `cksec_` secret — shown only in this response. Takes effect
        immediately: hold both old and new secrets during rollout, verify incoming
        signatures against either, then drop the old.
      x-required-scope: offers:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The new secret.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SecretRotationResult" }
              examples:
                rotated:
                  value:
                    secret: cksec_3b8e1f6a2c9d4e7f0a5b8c1d4e7f0a3b
                    rotated_at: "2026-07-11T14:06:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/inventory:
    post:
      operationId: uploadInventory
      tags: [Inventory]
      summary: Bulk upload keys / codes / files
      description: |
        Only for `stock_mode: uploaded` offers (`409` / `stock_mode_mismatch` otherwise).
        Limits: **1–1,000 items per request**; `text` values 1–1,024 chars; `file` content
        ≤ **200 KB decoded**, types `application/pdf`, `image/png`, `image/jpeg`; total body
        ≤ 1 MB. **Send `Idempotency-Key`** — networks drop, keys must not double-insert.

        - `mode: strict` (default) — all-or-nothing: any invalid item →
          `422` / `inventory_rejected` with per-item errors under `rejected_items`,
          **nothing stored**.
        - `mode: partial` — valid items stored, invalid ones reported in `rejected`.

        Duplicate detection: SHA-256 fingerprint of the exact value (or file bytes) per
        offer; an item matching any non-invalidated existing item is `duplicate_item`.
        Deliverables are encrypted at rest the moment they arrive; the API never returns a
        full value after upload.
      x-required-scope: inventory:write
      x-rate-limit-class: bulk
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InventoryUploadRequest" }
            examples:
              mixed-batch:
                value:
                  mode: strict
                  items:
                    - { kind: text, value: AAAA-BBBB-CCCC-DDDD }
                    - { kind: text, value: EEEE-FFFF-GGGG-HHHH }
                    - kind: file
                      file_name: giftcard-0042.pdf
                      content_type: application/pdf
                      content_base64: JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoK…
      responses:
        "201":
          description: Items stored (strict success, or partial with per-item rejections).
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryUploadResult" }
              examples:
                partial-with-duplicate:
                  value:
                    accepted: 2
                    rejected:
                      - index: 2
                        code: duplicate_item
                        message: Identical item already exists on this offer.
                    item_ids: [01JZWY6P2R4T6V8X0Z2B4D6F8H, 01JZWY6P2SAB4CD6EF8GH0JK2M]
                    stock_available: 1042
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`stock_mode_mismatch` (declared offer) or an idempotency conflict."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                mismatch:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#stock_mode_mismatch"
                    title: Stock mode mismatch
                    status: 409
                    detail: Inventory upload is only available on uploaded-stock offers.
                    code: stock_mode_mismatch
                    trace_id: req_0a1b2c3d4e5f6071
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422":
          description: >-
            `inventory_rejected` — strict-mode upload had invalid items; per-item errors in
            `rejected_items`, nothing stored. Or `validation_failed` for malformed envelope.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                strict-failure:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#inventory_rejected"
                    title: Inventory rejected
                    status: 422
                    detail: 1 of 3 items is invalid; strict mode stored nothing.
                    code: inventory_rejected
                    rejected_items:
                      - index: 2
                        code: duplicate_item
                        message: Identical item already exists on this offer.
                    trace_id: req_6f7a8b9c0d1e2f30
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      operationId: listInventory
      tags: [Inventory]
      summary: List inventory items
      description: >-
        Paginated inventory listing. `value_preview` shows the first 4 characters with the
        remainder masked — enough to reconcile, useless to steal.
      x-required-scope: inventory:read
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - name: state
          in: query
          schema: { $ref: "#/components/schemas/InventoryState" }
        - name: uploaded_after
          in: query
          schema: { type: string, format: date-time }
        - name: uploaded_before
          in: query
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of inventory items.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/InventoryItem" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                delivered-item:
                  value:
                    data:
                      - id: 01JZWY6P2R4T6V8X0Z2B4D6F8H
                        offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                        kind: text
                        state: delivered
                        value_preview: AAAA-••••-••••-••••
                        fingerprint: "sha256:9f8a1c…e2d4"
                        file_name: null
                        uploaded_at: "2026-07-01T09:00:00Z"
                        delivered_order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                        state_changed_at: "2026-07-11T14:03:40Z"
                    next_cursor: eyJrIjoiMDFKWldZNlAifQ
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /offers/{offer_id}/inventory/invalidate:
    post:
      operationId: invalidateInventory
      tags: [Inventory]
      summary: Invalidate available items (bulk)
      description: |
        Pull items from sale (compromised batch, publisher revocation). Only `available`
        items can be invalidated; others are reported in `skipped` with their current state.
        1–100 ids per call. If invalidation drops stock to/below `low_stock_threshold`,
        `offer.stock_low` fires.
      x-required-scope: inventory:write
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/OfferId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InventoryInvalidateRequest" }
            examples:
              two-ids:
                value:
                  item_ids: [01JZWY6P2R4T6V8X0Z2B4D6F8H, 01JZWY6P2SAB4CD6EF8GH0JK2M]
      responses:
        "200":
          description: Invalidation result with per-item skips.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InventoryInvalidateResult" }
              examples:
                one-skipped:
                  value:
                    invalidated: 1
                    skipped:
                      - id: 01JZWY6P2R4T6V8X0Z2B4D6F8H
                        code: invalid_state
                        state: delivered
                    stock_available: 1040
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /orders:
    get:
      operationId: listOrders
      tags: [Orders]
      summary: List orders containing your items
      description: |
        Every order containing at least one of your items, with **only your items**, and no
        buyer PII beyond `buyer_country`. Use `updated_since` for delta polling — but prefer
        webhooks.
      x-required-scope: orders:read
      x-rate-limit-class: reads
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/OrderStatus" }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
        - name: product_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: offer_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of orders (supplier-scoped view).
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                one-completed:
                  value:
                    data:
                      - id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                        number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                        status: completed
                        buyer_country: FR
                        currency: EUR
                        created_at: "2026-07-11T14:02:58Z"
                        paid_at: "2026-07-11T14:03:22Z"
                        completed_at: "2026-07-11T14:03:41Z"
                        items:
                          - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                            product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                            offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                            external_ref: SKU-STARFORGE-GLOBAL
                            quantity: 1
                            unit_price: { amount: 1999, currency: EUR }
                            gross: { amount: 1999, currency: EUR }
                            commission: { amount: 170, currency: EUR }
                            net: { amount: 1829, currency: EUR }
                            status: delivered
                            fulfilment:
                              source: declared
                              reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                              inventory_item_ids: null
                            delivered_at: "2026-07-11T14:03:40Z"
                        totals:
                          gross: { amount: 1999, currency: EUR }
                          commission: { amount: 170, currency: EUR }
                          net: { amount: 1829, currency: EUR }
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /orders/{order_id}:
    get:
      operationId: getOrder
      tags: [Orders]
      summary: Order detail (supplier-scoped)
      description: |
        The order with only your items. `fulfilment.source` is `uploaded` (carries
        `inventory_item_ids`), `declared` (carries `reservation_id`), or `topup` (carries
        `operation_id`). Totals cover **your** items only. Commission math:
        `max(round_half_up(gross × 0.085), 5)` per item.
      x-required-scope: orders:read
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: The order.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
              examples:
                completed-declared:
                  value:
                    id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: completed
                    buyer_country: FR
                    currency: EUR
                    created_at: "2026-07-11T14:02:58Z"
                    paid_at: "2026-07-11T14:03:22Z"
                    completed_at: "2026-07-11T14:03:41Z"
                    items:
                      - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                        product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                        offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                        external_ref: SKU-STARFORGE-GLOBAL
                        quantity: 1
                        unit_price: { amount: 1999, currency: EUR }
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
                        status: delivered
                        fulfilment:
                          source: declared
                          reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                          inventory_item_ids: null
                        delivered_at: "2026-07-11T14:03:40Z"
                    totals:
                      gross: { amount: 1999, currency: EUR }
                      commission: { amount: 170, currency: EUR }
                      net: { amount: 1829, currency: EUR }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /balance:
    get:
      operationId: getBalance
      tags: [Balance]
      summary: Available + pending balance
      description: >-
        `pending` = net proceeds inside the 7-day holding period (10 minutes in sandbox).
        Payout execution is portal-only in v1; payouts appear in the ledger.
      x-required-scope: balance:read
      x-rate-limit-class: reads
      responses:
        "200":
          description: Current balances.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
              examples:
                balances:
                  value:
                    currency: EUR
                    available: { amount: 184230, currency: EUR }
                    pending: { amount: 12704, currency: EUR }
                    updated_at: "2026-07-11T14:00:05Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /balance/entries:
    get:
      operationId: listBalanceEntries
      tags: [Balance]
      summary: Ledger entries
      description: |
        Append-only ledger (schema canonical in 01-data-model.md §2.7 — one row per economic
        event with gross/commission/net; `amount` is the net, `breakdown` the split). `sale`
        credits the **net**, created at delivery, `pending` until `available_at`.
        `refund_reversal` / `invalidation_chargeback` debit exactly the net of the affected
        items — commission is returned to you (symmetric; no fee kept on refunds). `payout`,
        `payout_reversal` and `adjustment` (staff, always carries `description`) are signed
        accordingly. Amounts can be negative.
      x-required-scope: balance:read
      x-rate-limit-class: reads
      parameters:
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/LedgerEntryType" }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/LedgerEntryStatus" }
        - name: order_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of ledger entries.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LedgerEntry" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                sale-and-payout:
                  value:
                    data:
                      - id: 01JZY4C3D5F7H9K1M3P5R7T9V1
                        type: sale
                        amount: { amount: 1829, currency: EUR }
                        breakdown: { gross: 1999, commission: 170 }
                        status: pending
                        available_at: "2026-07-18T14:03:40Z"
                        order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                        order_item_id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                        description: null
                        created_at: "2026-07-11T14:03:40Z"
                      - id: 01JZY4C4E6G8J0K2M4N6P8Q0R2
                        type: payout
                        amount: { amount: -150000, currency: EUR }
                        breakdown: null
                        status: settled
                        available_at: null
                        order_id: null
                        order_item_id: null
                        description: "SEPA payout #2026-018"
                        created_at: "2026-07-08T06:00:00Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks:
    get:
      operationId: listWebhookEndpoints
      tags: [Webhooks]
      summary: List webhook endpoints
      description: Endpoints are returned without their `secret` (shown once at creation).
      x-required-scope: webhooks:manage
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of webhook endpoints.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                one-endpoint:
                  value:
                    data:
                      - id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                        url: https://api.keyforge.example/hooks/cheap-keys
                        events: [order.paid, order.refunded, order.disputed, offer.stock_low]
                        description: Main ERP intake
                        status: active
                        consecutive_failures: 0
                        disabled_at: null
                        created_at: "2026-07-11T10:00:00Z"
                    next_cursor: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createWebhookEndpoint
      tags: [Webhooks]
      summary: Create a webhook endpoint (secret shown once)
      description: |
        Max **10 endpoints** per supplier per environment. `events` is any subset of the
        event catalog, or `["*"]` for everything. URL rules: HTTPS, publicly resolvable, not
        a private/link-local IP, ≤ 2,048 chars. The `201` response is **the only time the
        signing secret (`whsec_…`) is shown** — store it; rotate to obtain a new one.
      x-required-scope: webhooks:manage
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreateRequest" }
            examples:
              erp-intake:
                value:
                  url: https://api.keyforge.example/hooks/cheap-keys
                  events: [order.paid, order.refunded, order.disputed, offer.stock_low]
                  description: Main ERP intake
      responses:
        "201":
          description: Endpoint created — includes the signing secret, shown once only.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }
              examples:
                created:
                  value:
                    id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                    url: https://api.keyforge.example/hooks/cheap-keys
                    events: [order.paid, order.refunded, order.disputed, offer.stock_low]
                    description: Main ERP intake
                    status: active
                    secret: whsec_4d9c2f81a7e35b60d1c8f42e9a0b7365
                    consecutive_failures: 0
                    disabled_at: null
                    created_at: "2026-07-11T10:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhook_id}:
    get:
      operationId: getWebhookEndpoint
      tags: [Webhooks]
      summary: Webhook endpoint detail
      description: Same object as the list — never includes `secret`.
      x-required-scope: webhooks:manage
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "200":
          description: The endpoint.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
              examples:
                endpoint:
                  value:
                    id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                    url: https://api.keyforge.example/hooks/cheap-keys
                    events: [order.paid, order.refunded, order.disputed, offer.stock_low]
                    description: Main ERP intake
                    status: active
                    consecutive_failures: 0
                    disabled_at: null
                    created_at: "2026-07-11T10:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      operationId: updateWebhookEndpoint
      tags: [Webhooks]
      summary: Update URL / events / description / status
      description: >-
        Re-enabling a disabled endpoint (`status: active`) requires a passing 2xx test-fire
        first (409 / invalid_state otherwise) and resets `consecutive_failures`
        to 0.
      x-required-scope: webhooks:manage
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointUpdateRequest" }
            examples:
              re-enable:
                value: { status: active }
              change-events:
                value: { events: ["*"] }
      responses:
        "200":
          description: The updated endpoint (no `secret`).
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
              examples:
                re-enabled:
                  value:
                    id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                    url: https://api.keyforge.example/hooks/cheap-keys
                    events: [order.paid, order.refunded, order.disputed, offer.stock_low]
                    description: Main ERP intake
                    status: active
                    consecutive_failures: 0
                    disabled_at: null
                    created_at: "2026-07-11T10:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      x-required-scope: webhooks:manage
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "204":
          description: Deleted. No body.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhook_id}/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      tags: [Webhooks]
      summary: Rotate the signing secret
      description: >-
        Returns a new `whsec_` secret, shown only in this response. Effective immediately —
        verify against both secrets during your rollout, then drop the old.
      x-required-scope: webhooks:manage
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The new secret.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SecretRotationResult" }
              examples:
                rotated:
                  value:
                    secret: whsec_8c2f5e9a1b4d7c0e3f6a9b2c5d8e1f40
                    rotated_at: "2026-07-11T14:20:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{webhook_id}/test:
    post:
      operationId: testWebhookEndpoint
      tags: [Webhooks]
      summary: Test-fire a signed fixture event
      description: |
        Sends a **fully signed** canned fixture of the chosen event type (default
        `order.paid`) to the endpoint, with envelope flag `"test_fire": true`, synchronously,
        and returns the result. Test-fires do not affect `consecutive_failures`. Available in
        both live and sandbox. Rate class `test` (6/min).
      x-required-scope: webhooks:manage
      x-rate-limit-class: test
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookTestRequest" }
            examples:
              specific-event:
                value: { event: order.paid }
      responses:
        "200":
          description: Synchronous delivery result.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookTestResult" }
              examples:
                success:
                  value:
                    delivery_id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2
                    event_type: order.paid
                    response_status: 200
                    duration_ms: 187
                    succeeded: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhook-deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhook deliveries]
      summary: Search the delivery log
      description: >-
        One row per delivery **attempt**. Retention **30 days**. Everything needed to debug
        without a support ticket.
      x-required-scope: webhooks:manage
      x-rate-limit-class: reads
      parameters:
        - name: endpoint_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: event_id
          in: query
          schema: { $ref: "#/components/schemas/Ulid" }
        - name: event_type
          in: query
          schema: { $ref: "#/components/schemas/WebhookEventType" }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/WebhookDeliveryStatus" }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
        - name: created_before
          in: query
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of delivery attempts.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                failed-attempt:
                  value:
                    data:
                      - id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2
                        webhook_endpoint_id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                        event_id: 01JZY3B1M5P7R9T2V4X6Z8A0C2
                        event_type: order.paid
                        attempt: 2
                        status: failed
                        response_status: 500
                        duration_ms: 3012
                        error: HTTP 500
                        next_attempt_at: "2026-07-11T14:09:00Z"
                        created_at: "2026-07-11T14:04:00Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhook-deliveries/{delivery_id}:
    get:
      operationId: getWebhookDelivery
      tags: [Webhook deliveries]
      summary: Delivery attempt detail (with bodies)
      description: >-
        Additionally returns `request_body` (the exact signed payload) and `response_body`
        (first 4 KB).
      x-required-scope: webhooks:manage
      x-rate-limit-class: reads
      parameters:
        - $ref: "#/components/parameters/DeliveryId"
      responses:
        "200":
          description: The delivery attempt with request/response bodies.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDeliveryDetail" }
              examples:
                detail:
                  value:
                    id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2
                    webhook_endpoint_id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                    event_id: 01JZY3B1M5P7R9T2V4X6Z8A0C2
                    event_type: order.paid
                    attempt: 2
                    status: failed
                    response_status: 500
                    duration_ms: 3012
                    error: HTTP 500
                    next_attempt_at: "2026-07-11T14:09:00Z"
                    created_at: "2026-07-11T14:04:00Z"
                    request_body: "{\"id\":\"01JZY3B1M5P7R9T2V4X6Z8A0C2\",\"type\":\"order.paid\",…}"
                    response_body: "{\"error\":\"internal\"}"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhook-deliveries/{delivery_id}/resend:
    post:
      operationId: resendWebhookDelivery
      tags: [Webhook deliveries]
      summary: Re-deliver an event to its endpoint
      description: |
        Queues a fresh attempt of that delivery's event to its endpoint. Works on succeeded
        or failed deliveries ≤ 30 days old, even if the endpoint's subscription changed.
      x-required-scope: webhooks:manage
      x-rate-limit-class: writes
      parameters:
        - $ref: "#/components/parameters/DeliveryId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Resend queued.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookResendResult" }
              examples:
                queued:
                  value:
                    delivery_id: 01JZY3B3P7R9T1V3X5Z7B9D1F3
                    status: pending
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sandbox/orders:
    post:
      operationId: createSandboxOrder
      x-sandbox-only: true
      tags: [Sandbox]
      summary: "Sandbox only: simulate a buyer order end-to-end"
      description: |
        **Sandbox only** — returns `404` / `not_found` on production. Requires a `ck_test_`
        token (any scope). Creates a real order against **your** offer and drives it through
        the chosen scenario, firing every webhook and — for declared/top-up offers —
        **actually calling your fulfilment endpoints** with real signatures. Bypasses payment
        entirely.

        | scenario | Behavior |
        |---|---|
        | `paid` (default) | created → paid → full fulfilment → completed |
        | `payment_failed` | created → payment fails → order cancelled (reservation released) — no `order.paid` |
        | `refund_after_delivery` | full `paid` flow, then ~60 s later a `key_invalid` refund (fires `order.refunded`, calls your return-to-stock) |
        | `dispute_after_delivery` | full `paid` flow, then ~60 s later a `key_invalid` dispute (fires `order.disputed`) |

        `form_fields` is required for top-up offers, forbidden otherwise.
      x-required-scope: any ck_test_ token
      x-rate-limit-class: test
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SandboxOrderRequest" }
            examples:
              topup-paid:
                value:
                  offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                  quantity: 1
                  buyer_country: FR
                  scenario: paid
                  form_fields: { user_id: "51231234", server_id: "2001" }
              key-refund:
                value:
                  offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                  quantity: 1
                  buyer_country: DE
                  scenario: refund_after_delivery
      responses:
        "201":
          description: The simulated order (supplier-scoped order object).
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
            Idempotent-Replay: { $ref: "#/components/headers/Idempotent-Replay" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }
              examples:
                created:
                  value:
                    id: 01K00SB0X0000000000000ORD1
                    number: CK-01K00SB0X0000000000000ORD1
                    status: pending
                    buyer_country: FR
                    currency: EUR
                    created_at: "2026-07-11T15:00:00Z"
                    paid_at: null
                    completed_at: null
                    items:
                      - id: 01K00SB0X0000000000000ITM1
                        product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                        offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                        external_ref: SKU-STARFORGE-GLOBAL
                        quantity: 1
                        unit_price: { amount: 1999, currency: EUR }
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
                        status: reserved
                        fulfilment:
                          source: declared
                          reservation_id: 01K00SB0X0000000000000RSV1
                          inventory_item_ids: null
                        delivered_at: null
                    totals:
                      gross: { amount: 1999, currency: EUR }
                      commission: { amount: 170, currency: EUR }
                      net: { amount: 1829, currency: EUR }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "`not_found` — always on production; or the offer is not yours / does not exist."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                production:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#not_found"
                    title: Not found
                    status: 404
                    detail: This endpoint exists only in sandbox.
                    code: not_found
                    trace_id: req_e1f2a3b4c5d6e7f8
        "409": { $ref: "#/components/responses/Conflict" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sandbox/webhook-deliveries:
    get:
      operationId: listSandboxWebhookDeliveries
      x-sandbox-only: true
      tags: [Sandbox]
      summary: "Sandbox only: read the webhook deliveries a sandbox order produced"
      description: |
        **Sandbox only** — returns `404` / `not_found` on production. Requires a `ck_test_`
        token (**any scope** — unlike the real `GET /webhook-deliveries`, which needs the
        mutating `webhooks:manage` scope; the published read-only sandbox token has none).
        Lists the delivery attempts for **your own** endpoints, so an integrator can read
        back the signed `order.paid` delivery their `POST /sandbox/orders` call produced.

        Filter by `event_type` and by `resource_id` (the sandbox order id) to pinpoint a
        single order's delivery. Same `{ data, next_cursor }` keyset envelope and
        `WebhookDelivery` row shape as the real delivery log.
      x-required-scope: any ck_test_ token
      x-rate-limit-class: reads
      parameters:
        - name: event_type
          in: query
          schema: { $ref: "#/components/schemas/WebhookEventType" }
        - name: resource_id
          in: query
          description: The sandbox order id whose delivery you want to read back.
          schema: { $ref: "#/components/schemas/Ulid" }
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of delivery attempts for your sandbox endpoints.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
                  next_cursor: { $ref: "#/components/schemas/NextCursor" }
              examples:
                order-paid:
                  value:
                    data:
                      - id: 01JZY3B2N6Q8S0V2X4Z6B8D0F2
                        webhook_endpoint_id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                        event_id: 01JZY3B1M5P7R9T2V4X6Z8A0C2
                        event_type: order.paid
                        attempt: 1
                        status: succeeded
                        response_status: 200
                        duration_ms: 42
                        error: null
                        next_attempt_at: null
                        created_at: "2026-07-11T15:00:01Z"
                    next_cursor: null
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: "`not_found` — always on production."
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
              examples:
                production:
                  value:
                    type: "https://pay.mmokick.com/developers/errors#not_found"
                    title: Not found
                    status: 404
                    detail: This endpoint exists only in sandbox.
                    code: not_found
                    trace_id: req_e1f2a3b4c5d6e7f8
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

webhooks:

  order.created:
    post:
      operationId: onOrderCreated
      tags: [Webhook events]
      summary: An order containing ≥1 of your items is placed (before payment)
      description: |
        For declared-stock suppliers: the `POST /reservations` call to your fulfilment
        endpoint happens at checkout initiation and may arrive **before** this event.
        Unpaid orders expire after the checkout TTL; expiry emits a terminal
        `order.expired` event (and your reservation TTLs out / is released).
        Respond `2xx` within 10 s; process asynchronously. Verify `X-CK-Signature` before
        trusting the payload.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderCreatedEvent" }
            examples:
              pending-order:
                value:
                  id: 01JZY3A9K1M3P5R7T9V2X4Z6B8
                  type: order.created
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-11T14:02:58Z"
                  data:
                    order:
                      id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                      number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                      status: pending
                      buyer_country: FR
                      currency: EUR
                      created_at: "2026-07-11T14:02:58Z"
                      paid_at: null
                      completed_at: null
                      items:
                        - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                          product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                          offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                          external_ref: SKU-STARFORGE-GLOBAL
                          quantity: 1
                          unit_price: { amount: 1999, currency: EUR }
                          gross: { amount: 1999, currency: EUR }
                          commission: { amount: 170, currency: EUR }
                          net: { amount: 1829, currency: EUR }
                          status: delivered
                          fulfilment:
                            source: declared
                            reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                            inventory_item_ids: null
                          delivered_at: "2026-07-11T14:03:20Z"
                      totals:
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: >-
            Failure — the attempt is retried per the schedule (+0, +1 m, +5 m, +30 m, +2 h,
            +8 h, +24 h). A `410` response disables the endpoint immediately.

  order.paid:
    post:
      operationId: onOrderPaid
      tags: [Webhook events]
      summary: Payment captured — provisioning already ran before this event
      description: |
        Same `data.order` shape as `order.created`, with `status: "completed"` (or
        `"partially_completed"`), `paid_at` set. Fires **at capture**, which under the
        authorize → provision → capture flow (04 §4) happens *after* provisioning:
        declared-stock items are already `"delivered"`; top-up items show `"provisioning"`
        (their execute call follows capture).
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderPaidEvent" }
            examples:
              paid-order:
                value:
                  id: 01JZY3B1M5P7R9T2V4X6Z8A0C2
                  type: order.paid
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-11T14:03:22Z"
                  data:
                    order:
                      id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                      number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                      status: completed
                      buyer_country: FR
                      currency: EUR
                      created_at: "2026-07-11T14:02:58Z"
                      paid_at: "2026-07-11T14:03:22Z"
                      completed_at: "2026-07-11T14:03:22Z"
                      items:
                        - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                          product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                          offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                          external_ref: SKU-STARFORGE-GLOBAL
                          quantity: 1
                          unit_price: { amount: 1999, currency: EUR }
                          gross: { amount: 1999, currency: EUR }
                          commission: { amount: 170, currency: EUR }
                          net: { amount: 1829, currency: EUR }
                          status: reserved
                          fulfilment:
                            source: declared
                            reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                            inventory_item_ids: null
                          delivered_at: null
                      totals:
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  order.expired:
    post:
      operationId: onOrderExpired
      tags: [Webhook events]
      summary: An unpaid order containing ≥1 of your items expired (checkout abandoned)
      description: |
        The buyer never paid within the checkout TTL, so the order was swept to `expired`
        and every allocation released — declared reservations TTL out (or are marked
        `expired`), uploaded stock returns to `available`. `data.order.status` is
        `"expired"`; items are `"released"`. This is a **terminal** event: an ERP that
        recorded `order.created` can now close its record instead of leaving it dangling.
        Respond `2xx` within 10 s; verify `X-CK-Signature` before trusting the payload.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderExpiredEvent" }
            examples:
              expired-order:
                value:
                  id: 01JZY3B0K2M4P6R8T0V3X5Z7B9
                  type: order.expired
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-11T14:18:00Z"
                  data:
                    order:
                      id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                      number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                      status: expired
                      buyer_country: FR
                      currency: EUR
                      created_at: "2026-07-11T14:02:58Z"
                      paid_at: null
                      completed_at: null
                      items:
                        - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                          product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                          offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                          external_ref: SKU-STARFORGE-GLOBAL
                          quantity: 1
                          unit_price: { amount: 1999, currency: EUR }
                          gross: { amount: 1999, currency: EUR }
                          commission: { amount: 170, currency: EUR }
                          net: { amount: 1829, currency: EUR }
                          status: released
                          fulfilment:
                            source: declared
                            reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                            inventory_item_ids: null
                          delivered_at: null
                      totals:
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  order.refunded:
    post:
      operationId: onOrderRefunded
      tags: [Webhook events]
      summary: A refund covering ≥1 of your items is executed
      description: |
        For uploaded items not yet revealed, the item returns to `available` (listed in
        `return_to_stock.inventory_item_ids`); for declared items we call your
        `DELETE /orders/{id}/items` (`return_to_stock.declared_return_called`). The linked
        ledger entry debits exactly the net of the refunded items — commission is returned
        to you.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderRefundedEvent" }
            examples:
              invalid-key-refund:
                value:
                  id: 01JZY6D2F4H6K8M0P2R4T6V8X0
                  type: order.refunded
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-12T09:15:00Z"
                  data:
                    order:
                      id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                      number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                      status: refunded
                      buyer_country: FR
                      currency: EUR
                      created_at: "2026-07-11T14:02:58Z"
                      paid_at: "2026-07-11T14:03:22Z"
                      completed_at: "2026-07-11T14:03:41Z"
                      items:
                        - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                          product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                          offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                          external_ref: SKU-STARFORGE-GLOBAL
                          quantity: 1
                          unit_price: { amount: 1999, currency: EUR }
                          gross: { amount: 1999, currency: EUR }
                          commission: { amount: 170, currency: EUR }
                          net: { amount: 1829, currency: EUR }
                          status: refunded
                          fulfilment:
                            source: declared
                            reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                            inventory_item_ids: null
                          delivered_at: "2026-07-11T14:03:40Z"
                      totals:
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
                    refund:
                      id: 01JZY6D1E3G5J7K9M1N3P5Q7R9
                      order_item_ids: [01JZY2H8KFAB3CD5EF7GH9JK1M]
                      amount: { amount: 1999, currency: EUR }
                      reason: key_invalid
                      created_at: "2026-07-12T09:15:00Z"
                    ledger_entry_id: 01JZY6D2G4J6L8N0Q2S4V6X8Z0
                    return_to_stock:
                      attempted: true
                      inventory_item_ids: []
                      declared_return_called: true
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  order.disputed:
    post:
      operationId: onOrderDisputed
      tags: [Webhook events]
      summary: A buyer claim or card dispute was opened touching one of your items
      description: |
        **Informational** — we are merchant of record (04 §9): buyers deal with our support;
        there is no seller-response window. An upheld claim arrives later as `order.refunded`
        with a ledger `refund_reversal`; the affected `sale` ledger rows are held while the
        dispute is open. `message` is buyer text, ≤ 500 chars.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderDisputedEvent" }
            examples:
              key-invalid:
                value:
                  id: 01JZY5C1D3F5H7J9K1M3N5P7Q9
                  type: order.disputed
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-12T08:00:00Z"
                  data:
                    order:
                      id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                      number: CK-01JZY2H8K4M6N8P0Q2R4S6T8V0
                      status: completed
                      buyer_country: FR
                      currency: EUR
                      created_at: "2026-07-11T14:02:58Z"
                      paid_at: "2026-07-11T14:03:22Z"
                      completed_at: "2026-07-11T14:03:41Z"
                      items:
                        - id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                          product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                          offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                          external_ref: SKU-STARFORGE-GLOBAL
                          quantity: 1
                          unit_price: { amount: 1999, currency: EUR }
                          gross: { amount: 1999, currency: EUR }
                          commission: { amount: 170, currency: EUR }
                          net: { amount: 1829, currency: EUR }
                          status: delivered
                          fulfilment:
                            source: declared
                            reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                            inventory_item_ids: null
                          delivered_at: "2026-07-11T14:03:40Z"
                      totals:
                        gross: { amount: 1999, currency: EUR }
                        commission: { amount: 170, currency: EUR }
                        net: { amount: 1829, currency: EUR }
                    dispute:
                      id: 01JZY5C0C2E4G6J8K0M2N4P6Q8
                      order_item_id: 01JZY2H8KFAB3CD5EF7GH9JK1M
                      kind: buyer_claim
                      reason: key_invalid
                      message: Steam says the code was already redeemed.
                      opened_at: "2026-07-12T08:00:00Z"
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  offer.deactivated:
    post:
      operationId: onOfferDeactivated
      tags: [Webhook events]
      summary: Your offer leaves `active`, whoever caused it
      description: >-
        `reason` distinguishes supplier action, health exhaustion (24 h continuously in
        cooldown), staff action, and product unlisting.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OfferDeactivatedEvent" }
            examples:
              health-exhausted:
                value:
                  id: 01JZY7E3G5J7L9N1Q3S5V7X9Z1
                  type: offer.deactivated
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-12T03:40:00Z"
                  data:
                    offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    external_ref: SKU-STARFORGE-GLOBAL
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    reason: health_exhausted
                    detail: Offer spent 24h continuously in cooldown (reservation failure rate).
                    health_score: 12
                    deactivated_at: "2026-07-12T03:40:00Z"
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  offer.stock_low:
    post:
      operationId: onOfferStockLow
      tags: [Webhook events]
      summary: Uploaded stock crosses `low_stock_threshold` downward
      description: >-
        Fires once per downward crossing; re-arms when `available` rises above `threshold`.
        Uploaded offers only (declared stock is yours to watch).
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OfferStockLowEvent" }
            examples:
              crossed-threshold:
                value:
                  id: 01JZY8F4H6K8M0P2R4T6V8X0Z2
                  type: offer.stock_low
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-11T18:22:10Z"
                  data:
                    offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    external_ref: SKU-STARFORGE-GLOBAL
                    product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                    available: 4
                    threshold: 5
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  stock.invalidated:
    post:
      operationId: onStockInvalidated
      tags: [Webhook events]
      summary: Staff invalidated one or more of your uploaded inventory items
      description: >-
        Fires after an item is force-invalidated (never as a routine sale). `reason`
        distinguishes supplier recall, duplicate, known-compromised, and buyer-reported-invalid.
        A delivered item's invalidation may debit your balance and open the buyer's refund flow;
        the affected `item_ids` are removed from stock.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/EventEnvelope"
                - type: object
                  properties:
                    type: { const: stock.invalidated }
                    data:
                      type: object
                      required: [offer_id, external_ref, item_ids, reason]
                      properties:
                        offer_id: { $ref: "#/components/schemas/Ulid" }
                        external_ref: { type: [string, "null"] }
                        item_ids:
                          type: array
                          items: { $ref: "#/components/schemas/Ulid" }
                        reason:
                          type: string
                          enum: [supplier_recall, duplicate, known_compromised, buyer_reported_invalid]
            examples:
              publisher-revocation:
                value:
                  id: 01JZYAG6K8M0P2R4T6V8X0Z2B4
                  type: stock.invalidated
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-12T04:15:00Z"
                  data:
                    offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                    external_ref: SKU-STARFORGE-GLOBAL
                    item_ids: [01JZWY6P2R4T6V8X0Z2B4D6F8H]
                    reason: known_compromised
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

  webhook.disabled:
    post:
      operationId: onWebhookDisabled
      tags: [Webhook events]
      summary: One of your endpoints was auto-disabled
      description: |
        Delivered to your **other** enabled endpoints (plus portal alert + email — always,
        even if this was your only endpoint). Re-enable via
        `PATCH /webhooks/{webhook_id}` after fixing; that resets `consecutive_failures`.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKEvent"
        - $ref: "#/components/parameters/XCKEventId"
        - $ref: "#/components/parameters/XCKDeliveryId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookDisabledEvent" }
            examples:
              thirty-failures:
                value:
                  id: 01JZY9G5J7L9N1Q3S5V7X9Z1B3
                  type: webhook.disabled
                  mode: live
                  api_version: v1
                  test_fire: false
                  created_at: "2026-07-12T05:00:00Z"
                  data:
                    webhook_endpoint_id: 01JZW0DC8FGH2JK4MN6PQ8RS0T
                    url: https://api.keyforge.example/hooks/cheap-keys
                    reason: consecutive_failures
                    consecutive_failures: 30
                    last_error: { response_status: 500, message: HTTP 500 }
                    disabled_at: "2026-07-12T05:00:00Z"
      responses:
        "2XX":
          description: Acknowledged. Any 2xx within 10 s counts as delivered.
        default:
          description: Failure — retried per schedule; `410` disables the endpoint immediately.

components:

  securitySchemes:
    bearerToken:
      type: http
      scheme: bearer
      bearerFormat: "ck_live_* / ck_test_* opaque token"
      description: |
        `Authorization: Bearer <token>`. Tokens are `ck_live_` (production) or `ck_test_`
        (sandbox) + 40 base62 characters, supplier-scoped, created in the supplier portal,
        shown once, revocable instantly. Scopes: `catalog:read`, `pricing:read`,
        `offers:read`, `offers:write`, `inventory:read`, `inventory:write`, `orders:read`,
        `balance:read`, `webhooks:manage`. Each operation's required scope is in
        `x-required-scope`. Wrong-environment tokens get `401` / `wrong_environment`.

  parameters:
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `next_cursor`. Expires after 24 h.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Any string, 1–200 chars — a UUID/ULID per logical operation is recommended.
        Scope `(token, method, path, key)`, retained 24 h. Replay with identical body
        returns the original response plus `Idempotent-Replay: true`; a different body is
        `409` / `idempotency_conflict`; a still-running original is
        `409` / `idempotency_in_flight` with `Retry-After: 2`.
      schema: { type: string, minLength: 1, maxLength: 200 }
    ProductId:
      name: product_id
      in: path
      required: true
      description: Product ULID.
      schema: { $ref: "#/components/schemas/Ulid" }
    OfferId:
      name: offer_id
      in: path
      required: true
      description: Offer ULID.
      schema: { $ref: "#/components/schemas/Ulid" }
    OrderId:
      name: order_id
      in: path
      required: true
      description: Order ULID (bare, without the `CK-` prefix).
      schema: { $ref: "#/components/schemas/Ulid" }
    WebhookId:
      name: webhook_id
      in: path
      required: true
      description: Webhook endpoint ULID.
      schema: { $ref: "#/components/schemas/Ulid" }
    DeliveryId:
      name: delivery_id
      in: path
      required: true
      description: Webhook delivery attempt ULID.
      schema: { $ref: "#/components/schemas/Ulid" }
    XCKSignature:
      name: X-CK-Signature
      in: header
      required: true
      description: |
        `v1=hex(HMAC-SHA256(secret, "<X-CK-Timestamp>.<raw body>"))`. Verify against the
        **raw body** with a constant-time comparison. Future schemes ship as `v2=` alongside
        `v1=` (comma-separated), never replacing it silently.
      schema:
        type: string
        pattern: "^v1=[a-f0-9]{64}$"
        examples: ["v1=8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a"]
    XCKTimestamp:
      name: X-CK-Timestamp
      in: header
      required: true
      description: Unix milliseconds at signing time. Reject when |now − timestamp| > 5 minutes.
      schema:
        type: integer
        format: int64
        examples: [1783519402193]
    XCKEvent:
      name: X-CK-Event
      in: header
      required: true
      description: The event type, duplicated from the envelope for cheap routing.
      schema: { $ref: "#/components/schemas/WebhookEventType" }
    XCKEventId:
      name: X-CK-Event-Id
      in: header
      required: true
      description: The event ULID (envelope `id`) — your dedupe key.
      schema: { $ref: "#/components/schemas/Ulid" }
    XCKDeliveryId:
      name: X-CK-Delivery-Id
      in: header
      required: true
      description: The delivery-attempt ULID; look it up in `GET /webhook-deliveries/{delivery_id}`.
      schema: { $ref: "#/components/schemas/Ulid" }

  headers:
    X-Request-Id:
      description: >-
        Unique request id, echoed as `trace_id` in problem bodies. Quote it in support
        requests.
      schema: { type: string }
    X-RateLimit-Limit:
      description: Request quota for this token's rate-limit class in the current 60 s window.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix seconds when the window resets.
      schema: { type: integer, format: int64 }
    Retry-After:
      description: Seconds to wait before retrying.
      schema: { type: integer }
    Idempotent-Replay:
      description: >-
        Present with value `true` when this response is a stored replay of an earlier
        request with the same Idempotency-Key.
      schema: { type: string, enum: ["true"] }

  responses:
    BadRequest:
      description: "`invalid_json`, `invalid_cursor`, or `idempotency_key_invalid`."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            invalid-cursor:
              value:
                type: "https://pay.mmokick.com/developers/errors#invalid_cursor"
                title: Invalid cursor
                status: 400
                detail: This pagination cursor is unknown or has expired; restart without a cursor.
                code: invalid_cursor
                trace_id: req_1a2b3c4d5e6f7081
    Unauthorized:
      description: >-
        `unauthenticated` (missing/malformed header), `invalid_token`, `token_revoked`, or
        `wrong_environment` (ck_test_ on prod / ck_live_ on sandbox).
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            wrong-environment:
              value:
                type: "https://pay.mmokick.com/developers/errors#wrong_environment"
                title: Wrong environment
                status: 401
                detail: "This is a ck_test_ token; use https://sandbox-pay.mmokick.com/api/v1."
                code: wrong_environment
                trace_id: req_90ab12cd34ef5678
    Forbidden:
      description: "`insufficient_scope` or `supplier_suspended`."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            insufficient-scope:
              value:
                type: "https://pay.mmokick.com/developers/errors#insufficient_scope"
                title: Insufficient scope
                status: 403
                detail: "This operation requires scope offers:write."
                code: insufficient_scope
                trace_id: req_44cc55dd66ee77f0
    NotFound:
      description: "`not_found` — no such resource **or not yours** (no existence disclosure)."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            not-found:
              value:
                type: "https://pay.mmokick.com/developers/errors#not_found"
                title: Not found
                status: 404
                detail: No such resource.
                code: not_found
                trace_id: req_deadbeef00112233
    Conflict:
      description: >-
        `409` conflicts: `offer_exists`, `invalid_state`, `stock_mode_mismatch`,
        `idempotency_conflict`, or `idempotency_in_flight` (the latter carries
        `Retry-After: 2`). The operation description lists which apply.
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        Retry-After: { $ref: "#/components/headers/Retry-After" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            idempotency-conflict:
              value:
                type: "https://pay.mmokick.com/developers/errors#idempotency_conflict"
                title: Idempotency conflict
                status: 409
                detail: This Idempotency-Key was already used with a different request body.
                code: idempotency_conflict
                trace_id: req_5566778899aabbcc
            idempotency-in-flight:
              value:
                type: "https://pay.mmokick.com/developers/errors#idempotency_in_flight"
                title: Idempotency in flight
                status: 409
                detail: The original request with this key is still executing; retry shortly.
                code: idempotency_in_flight
                trace_id: req_ffee00112233aabb
    PayloadTooLarge:
      description: "`payload_too_large` — body exceeds 1 MB; split the batch."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            too-large:
              value:
                type: "https://pay.mmokick.com/developers/errors#payload_too_large"
                title: Payload too large
                status: 413
                detail: Request body exceeds 1 MB.
                code: payload_too_large
                trace_id: req_a1b2c3d4e5f60718
    UnsupportedMediaType:
      description: "`unsupported_media_type` — Content-Type must be application/json."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            wrong-content-type:
              value:
                type: "https://pay.mmokick.com/developers/errors#unsupported_media_type"
                title: Unsupported media type
                status: 415
                detail: Send Content-Type application/json.
                code: unsupported_media_type
                trace_id: req_0011223344556677
    ValidationFailed:
      description: >-
        `validation_failed` — field-level validation; the `errors` map contains
        field → messages.
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            field-errors:
              value:
                type: "https://pay.mmokick.com/developers/errors#validation_failed"
                title: Validation failed
                status: 422
                detail: price.amount must be a positive integer.
                code: validation_failed
                errors: { "price.amount": ["must be a positive integer"] }
                trace_id: req_9f3b2c61a8d44e0f
    RateLimited:
      description: "`rate_limited` — back off per `Retry-After`."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
        Retry-After: { $ref: "#/components/headers/Retry-After" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            limited:
              value:
                type: "https://pay.mmokick.com/developers/errors#rate_limited"
                title: Rate limited
                status: 429
                detail: Write limit (120/min) exceeded for this token.
                code: rate_limited
                trace_id: req_7788990011223344
    InternalError:
      description: "`internal_error` — our bug, logged under `trace_id`. Retry with backoff; report if persistent."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            internal:
              value:
                type: "https://pay.mmokick.com/developers/errors#internal_error"
                title: Internal error
                status: 500
                detail: Something went wrong on our side.
                code: internal_error
                trace_id: req_31337cafe0000042
    ServiceUnavailable:
      description: "`service_unavailable` — maintenance window; retry after `Retry-After`."
      headers:
        X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
        Retry-After: { $ref: "#/components/headers/Retry-After" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          examples:
            maintenance:
              value:
                type: "https://pay.mmokick.com/developers/errors#service_unavailable"
                title: Service unavailable
                status: 503
                detail: Scheduled maintenance; retry in 120 seconds.
                code: service_unavailable
                trace_id: req_maint00000000001

  schemas:

    Ulid:
      type: string
      pattern: "^[0-7][0-9A-HJKMNP-TV-Z]{25}$"
      description: 26-character Crockford base32 ULID.
      examples: [01JZY2H8K4M6N8P0Q2R4S6T8V0]

    NextCursor:
      type: [string, "null"]
      description: Opaque cursor for the next page; `null` on the last page. Expires after 24 h.

    CountryCode:
      type: string
      pattern: "^[A-Z]{2}$"
      description: ISO 3166-1 alpha-2 country code.
      examples: [FR]

    Money:
      type: object
      description: >-
        Integer minor units + ISO 4217 code. Supplier API v1 is EUR-only; any non-EUR
        currency in a request is 422 / validation_failed. Never floats, never strings.
      required: [amount, currency]
      properties:
        amount:
          type: integer
          description: Minor units (cents). Can be negative in ledger entries.
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
          description: "ISO 4217. v1: always EUR."
          examples: [EUR]

    ErrorCode:
      type: string
      description: |
        Stable machine code — the field integrations switch on. `title`/`detail` texts may
        change; codes never do. Codes are append-only: tolerate unknown values.
        Each code maps to exactly one HTTP status:
        400 invalid_json, invalid_cursor, idempotency_key_invalid ·
        401 unauthenticated, invalid_token, token_revoked, wrong_environment ·
        403 insufficient_scope, supplier_suspended · 404 not_found ·
        405 method_not_allowed · 409 offer_exists, invalid_state, stock_mode_mismatch,
        idempotency_conflict, idempotency_in_flight · 413 payload_too_large ·
        415 unsupported_media_type · 422 validation_failed, product_not_sellable,
        fulfilment_unreachable, inventory_rejected · 429 rate_limited ·
        500 internal_error · 503 service_unavailable.
      enum:
        - invalid_json
        - invalid_cursor
        - idempotency_key_invalid
        - unauthenticated
        - invalid_token
        - token_revoked
        - wrong_environment
        - insufficient_scope
        - supplier_suspended
        - not_found
        - method_not_allowed
        - offer_exists
        - invalid_state
        - stock_mode_mismatch
        - idempotency_conflict
        - idempotency_in_flight
        - payload_too_large
        - unsupported_media_type
        - validation_failed
        - product_not_sellable
        - fulfilment_unreachable
        - inventory_rejected
        - rate_limited
        - internal_error
        - service_unavailable

    ItemCode:
      type: string
      description: >-
        Per-item sub-code, appearing only inside `rejected` / `rejected_items` / `skipped`
        arrays — never top-level.
      enum:
        - duplicate_item
        - value_too_long
        - empty_value
        - file_too_large
        - invalid_base64
        - unsupported_content_type
        - invalid_state

    Problem:
      type: object
      title: Problem (RFC 9457)
      description: >-
        Every non-2xx response body, media type `application/problem+json`. `trace_id`
        equals the `X-Request-Id` header.
      required: [type, title, status, code, trace_id]
      properties:
        type:
          type: string
          format: uri
          description: "Always `https://pay.mmokick.com/developers/errors#<code>`."
        title:
          type: string
          description: Human summary. May change — switch on `code`, not this.
        status:
          type: integer
          description: The HTTP status, repeated.
        detail:
          type: string
          description: Human detail for this occurrence. May change.
        code: { $ref: "#/components/schemas/ErrorCode" }
        trace_id:
          type: string
          description: Request id — quote it in support requests.
        errors:
          type: object
          description: Field → messages map. Present only when `code` is `validation_failed`.
          additionalProperties:
            type: array
            items: { type: string }
        rejected_items:
          type: array
          description: Present only when `code` is `inventory_rejected` (strict-mode upload).
          items: { $ref: "#/components/schemas/RejectedInventoryItem" }

    RejectedInventoryItem:
      type: object
      required: [index, code, message]
      properties:
        index:
          type: integer
          description: Zero-based index into the submitted `items` array.
        code: { $ref: "#/components/schemas/ItemCode" }
        message: { type: string }

    ProductType:
      type: string
      enum: [game_key, gift_card, topup]

    Platform:
      type: string
      description: Extensible — new platforms may be added without a version bump; tolerate unknown values.
      enum: [steam, epic, gog, ea_app, ubisoft_connect, battle_net, rockstar, microsoft_store, xbox, psn, nintendo_eshop, mobile, other]

    Region:
      type: string
      enum: [global, europe, north_america, latin_america, asia, middle_east, oceania, row, country_locked]

    ProductStatus:
      type: string
      description: "`not_sellable` = archived or paused by staff; offer creation returns 422 / product_not_sellable."
      enum: [sellable, not_sellable]

    ProductAttributes:
      type: object
      description: >-
        Per-product structured key/values (string, string-array, or integer values). The
        attribute vocabulary per category is documented at
        https://pay.mmokick.com/developers.
      additionalProperties:
        oneOf:
          - type: string
          - type: integer
          - type: array
            items: { type: string }

    FormFieldOption:
      type: object
      required: [value, label]
      properties:
        value: { type: string }
        label: { type: string }

    FormField:
      type: object
      description: >-
        A declared buyer input for `topup` products. The platform validates buyer input
        against `required`/`pattern`/`max_length`/`options` before calling your top-up
        validate endpoint; you receive values keyed by `key`.
      required: [key, label, type, required, pattern, max_length, help_text, options]
      properties:
        key:
          type: string
          description: The machine key — values arrive in top-up calls keyed by this.
        label: { type: string }
        type:
          type: string
          enum: [text, number, select]
        required: { type: boolean }
        pattern:
          type: [string, "null"]
          description: Regular expression the value must match (text/number types).
        max_length:
          type: [integer, "null"]
        help_text:
          type: [string, "null"]
        options:
          type: [array, "null"]
          description: Present for `select` fields.
          items: { $ref: "#/components/schemas/FormFieldOption" }

    ProductMarket:
      type: object
      description: >-
        Supplier-facing market intelligence. No competitor identities are exposed.
      required: [buyer_price, active_offers, your_offer_selected]
      properties:
        buyer_price:
          oneOf:
            - $ref: "#/components/schemas/Money"
            - type: "null"
          description: Price of the currently routed offer; `null` if no active offer.
        active_offers:
          type: integer
          description: Count of competing active offers.
        your_offer_selected:
          type: boolean
          description: Whether your offer currently wins routing.

    Product:
      type: object
      required:
        - id
        - slug
        - name
        - type
        - platform
        - region
        - allowed_countries
        - blocked_countries
        - category_id
        - attributes
        - form_fields
        - status
        - market
        - created_at
        - updated_at
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        slug: { type: string }
        name: { type: string }
        type: { $ref: "#/components/schemas/ProductType" }
        platform: { $ref: "#/components/schemas/Platform" }
        region: { $ref: "#/components/schemas/Region" }
        allowed_countries:
          type: [array, "null"]
          description: >-
            ISO 3166-1 alpha-2 whitelist; `null` = no restriction. Buyer country is
            validated pre-purchase against these, so declared-stock suppliers rarely see
            out-of-region reservations.
          items: { $ref: "#/components/schemas/CountryCode" }
        blocked_countries:
          type: [array, "null"]
          description: ISO 3166-1 alpha-2 blacklist; `null` = no restriction.
          items: { $ref: "#/components/schemas/CountryCode" }
        category_id: { $ref: "#/components/schemas/Ulid" }
        attributes: { $ref: "#/components/schemas/ProductAttributes" }
        form_fields:
          type: array
          description: Empty except for `type:topup` products.
          items: { $ref: "#/components/schemas/FormField" }
        status: { $ref: "#/components/schemas/ProductStatus" }
        market: { $ref: "#/components/schemas/ProductMarket" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Category:
      type: object
      required: [id, name, slug, parent_id]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        name: { type: string }
        slug: { type: string }
        parent_id:
          oneOf:
            - $ref: "#/components/schemas/Ulid"
            - type: "null"
          description: Adjacency-list parent; `null` for roots.

    FeeSchedule:
      type: object
      required:
        - version
        - effective_from
        - currency
        - commission
        - listing_fee
        - price_change_fee
        - payment_fees_charged_to_supplier
        - payment_fees_charged_to_buyer
        - settlement
        - payout
        - unfulfilled_sale_fee
        - docs_url
      properties:
        version: { type: string, description: Fee schedule version (date-stamped). }
        currency: { type: string, examples: [EUR] }
        payment_fees_charged_to_buyer:
          type: boolean
          description: "v1: false — buyer payment fees are €0 for every method (04 §13)."
        payout:
          type: object
          required: [methods, minimum, fee]
          properties:
            methods:
              type: array
              items: { type: string, enum: [sepa_bank_transfer, paypal] }
            minimum:
              allOf: [{ $ref: "#/components/schemas/Money" }]
              description: "€20.00 (fees.min_payout_minor = 2000)."
            fee:
              oneOf: [{ $ref: "#/components/schemas/Money" }, { type: "null" }]
              description: "v1: null (none)."
        commission:
          type: object
          required: [rate_bps, min_fee, applies]
          properties:
            rate_bps:
              type: integer
              description: Basis points. v1 = 850 (8.5%).
            min_fee: { $ref: "#/components/schemas/Money" }
            applies: { type: string }
        listing_fee:
          oneOf: [{ $ref: "#/components/schemas/Money" }, { type: "null" }]
          description: "v1: null (none)."
        price_change_fee:
          oneOf: [{ $ref: "#/components/schemas/Money" }, { type: "null" }]
          description: "v1: null (none)."
        payment_fees_charged_to_supplier:
          type: boolean
          description: "v1: false."
        settlement:
          type: object
          required: [holding_period_days, trigger]
          properties:
            holding_period_days:
              type: integer
              description: 7 in production (sandbox uses a 10-minute clock).
            trigger:
              type: string
              enum: [delivery]
        unfulfilled_sale_fee:
          oneOf: [{ $ref: "#/components/schemas/Money" }, { type: "null" }]
          description: "v1: null — offer health + cooldowns are the penalty."
        effective_from: { type: string, format: date-time }
        docs_url: { type: string, format: uri }

    PricingSimulationRequest:
      type: object
      description: Provide **either** `price` **or** `target_net_payout` — both is 422.
      additionalProperties: false
      required: [product_id]
      properties:
        product_id: { $ref: "#/components/schemas/Ulid" }
        price: { $ref: "#/components/schemas/Money" }
        target_net_payout: { $ref: "#/components/schemas/Money" }
      oneOf:
        - required: [price]
        - required: [target_net_payout]

    PricingSimulationResult:
      type: object
      required: [product_id, price, buyer_price, commission, net_payout, settlement, market]
      properties:
        product_id: { $ref: "#/components/schemas/Ulid" }
        price:
          $ref: "#/components/schemas/Money"
          description: In reverse mode, the smallest price whose net ≥ target.
        buyer_price:
          $ref: "#/components/schemas/Money"
          description: Always equals `price` — buyers pay all-in; no checkout inflation, ever.
        commission:
          type: object
          required: [rate_bps, amount, min_fee_applied]
          properties:
            rate_bps: { type: integer }
            amount: { $ref: "#/components/schemas/Money" }
            min_fee_applied: { type: boolean }
        net_payout: { $ref: "#/components/schemas/Money" }
        settlement:
          type: object
          required: [holding_period_days]
          properties:
            holding_period_days: { type: integer }
        market:
          type: object
          required: [current_buyer_price, would_win_routing]
          properties:
            current_buyer_price:
              oneOf: [{ $ref: "#/components/schemas/Money" }, { type: "null" }]
            would_win_routing:
              type: boolean
              description: >-
                Assumes your offer is active and healthy; applies the routing rule
                (cheapest; tie-break delivery SLA then supplier age).

    OfferStatus:
      type: string
      description: >-
        Canonical enum (01-data-model.md §3.7). `paused_by_admin` is staff-only; `archived` is
        terminal. Cooldown is not a status — see `health.cooldown_until`.
      enum: [draft, active, paused, paused_by_admin, archived]

    StockMode:
      type: string
      description: Fixed at creation — to switch modes, archive and recreate.
      enum: [uploaded, declared]

    DeliveryTime:
      type: string
      description: >-
        Read-only label derived from `delivery_sla_minutes` (0 → instant, ≤60 → up_to_1h,
        ≤1440 → up_to_24h).
      enum: [instant, up_to_1h, up_to_24h]

    OfferStock:
      type: object
      description: >-
        For `uploaded`: available/reserved/delivered counts, declared null. For `declared`:
        the self-declared count (a routing signal, not a promise — the reservation call is
        the truth), available/reserved null.
      required: [mode, declared, available, reserved, delivered]
      properties:
        mode: { $ref: "#/components/schemas/StockMode" }
        declared: { type: [integer, "null"] }
        available: { type: [integer, "null"] }
        reserved: { type: [integer, "null"] }
        delivered:
          type: integer
          description: Lifetime delivered count.

    OfferFulfilment:
      type: object
      description: >-
        Present only when `stock_mode: declared` or the product type is `topup`. The
        signing secret is returned exactly once — on create and on rotate.
      required: [base_url, secret_set, last_healthcheck_at, last_healthcheck_ok]
      properties:
        base_url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS, publicly resolvable, not a private/link-local IP.
        secret:
          type: string
          description: >-
            `cksec_` signing secret — present ONLY in the POST /offers 201 response (and
            rotate-secret result). Never returned again.
        secret_set: { type: boolean }
        last_healthcheck_at: { type: [string, "null"], format: date-time }
        last_healthcheck_ok: { type: [boolean, "null"] }

    OfferHealth:
      type: object
      description: |
        Read-only, published math. Integer 0–100, starts at 100; routing requires
        `status: active` AND `score ≥ 70` AND not in cooldown AND a stock signal.
        Effects: delivered ≤60 s +2 (cap 100); delivered ≤10 min +1; reservation rejected
        −5; reservation timeout/5xx/malformed −8; POST /orders failed after retries −25 +
        cooldown; 10-min allocation window missed −25 + cooldown; top-up execute failed
        after payment −25 + cooldown; 3 consecutive healthcheck failures → cooldown;
        trailing-60-min reservation failure rate ≥40% with ≥5 attempts → cooldown.
        Cooldown = 2 h excluded from routing; extends in 2 h increments until a healthcheck
        passes; on exit health is raised to 70 if below. 24 h continuously in cooldown →
        auto-deactivation (`offer.deactivated`, reason `health_exhausted`).
      required: [score, routing_eligible, cooldown_until]
      properties:
        score: { type: integer, minimum: 0, maximum: 100 }
        routing_eligible: { type: boolean }
        cooldown_until: { type: [string, "null"], format: date-time }

    Offer:
      type: object
      required:
        - id
        - product_id
        - external_ref
        - status
        - stock_mode
        - price
        - delivery_time
        - low_stock_threshold
        - stock
        - fulfilment
        - health
        - created_at
        - updated_at
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        product_id: { $ref: "#/components/schemas/Ulid" }
        external_ref:
          type: [string, "null"]
          maxLength: 64
          description: >-
            Your SKU reference — unique per supplier, echoed on every callback and webhook.
        status: { $ref: "#/components/schemas/OfferStatus" }
        stock_mode: { $ref: "#/components/schemas/StockMode" }
        price: { $ref: "#/components/schemas/Money" }
        delivery_sla_minutes:
          type: integer
          minimum: 0
          maximum: 1440
          description: Stored SLA column (01 §2.3); writable with preset values 0 / 60 / 1440.
        delivery_time: { $ref: "#/components/schemas/DeliveryTime" }
        low_stock_threshold:
          type: [integer, "null"]
          description: Uploaded offers — `offer.stock_low` fires crossing this downward. Default 10.
        stock: { $ref: "#/components/schemas/OfferStock" }
        fulfilment:
          oneOf:
            - $ref: "#/components/schemas/OfferFulfilment"
            - type: "null"
        health: { $ref: "#/components/schemas/OfferHealth" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OfferFulfilmentConfig:
      type: object
      additionalProperties: false
      required: [base_url]
      properties:
        base_url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS, publicly resolvable, not a private/link-local IP, ≤ 2,048 chars.

    OfferCreateRequest:
      type: object
      additionalProperties: false
      required: [product_id, stock_mode, price]
      properties:
        product_id: { $ref: "#/components/schemas/Ulid" }
        stock_mode: { $ref: "#/components/schemas/StockMode" }
        price:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: "`amount` ≥ 10 (minimum sale €0.10) and ≤ 50,000,000. EUR only."
        external_ref:
          type: string
          maxLength: 64
          description: Unique per supplier; echoed on every callback and webhook.
        low_stock_threshold: { type: integer, minimum: 0 }
        fulfilment:
          allOf: [{ $ref: "#/components/schemas/OfferFulfilmentConfig" }]
          description: >-
            Required for `declared` offers and offers on `topup` products; forbidden
            (422) for `uploaded` offers.
        declared_stock:
          type: integer
          minimum: 0
          description: Initial declared count. Forbidden (422) for `uploaded` offers.

    OfferUpdateRequest:
      type: object
      description: Partial update — send only the fields you change.
      additionalProperties: false
      minProperties: 1
      properties:
        price:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: "`amount` ≥ 10 and ≤ 50,000,000."
        external_ref: { type: string, maxLength: 64 }
        low_stock_threshold: { type: integer, minimum: 0 }
        fulfilment:
          allOf: [{ $ref: "#/components/schemas/OfferFulfilmentConfig" }]
          description: >-
            Changing `base_url` on an active offer triggers an inline healthcheck
            (422 / fulfilment_unreachable on failure; nothing changed).
        declared_stock: { type: integer, minimum: 0 }

    PriceUpdateRequest:
      type: object
      additionalProperties: false
      required: [price]
      properties:
        price:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: "`amount` ≥ 10 and ≤ 50,000,000. EUR only."

    PriceUpdateResult:
      type: object
      required: [offer_id, price, updated_at]
      properties:
        offer_id: { $ref: "#/components/schemas/Ulid" }
        price: { $ref: "#/components/schemas/Money" }
        updated_at: { type: string, format: date-time }

    StockDeclareRequest:
      type: object
      additionalProperties: false
      required: [declared_stock]
      properties:
        declared_stock:
          type: integer
          minimum: 0
          description: "`0` makes the offer unroutable without deactivating it."

    StockDeclareResult:
      type: object
      required: [offer_id, declared_stock, updated_at]
      properties:
        offer_id: { $ref: "#/components/schemas/Ulid" }
        declared_stock: { type: integer }
        updated_at: { type: string, format: date-time }

    FulfilmentCheckResult:
      type: object
      description: A failed probe is still HTTP 200 here — the check ran; the result failed.
      required: [status, http_status, latency_ms, checked_at]
      properties:
        status:
          type: string
          enum: [ok, failed]
        http_status:
          type: [integer, "null"]
          description: "`null` when the connection could not be established at all."
        latency_ms: { type: integer }
        error:
          type: string
          description: Present only when `status` is `failed`.
        checked_at: { type: string, format: date-time }

    SecretRotationResult:
      type: object
      required: [secret, rotated_at]
      properties:
        secret:
          type: string
          description: >-
            The new secret (`cksec_` for fulfilment, `whsec_` for webhook endpoints) —
            shown only in this response. Takes effect immediately; verify against both old
            and new during rollout.
        rotated_at: { type: string, format: date-time }

    InventoryState:
      type: string
      description: >-
        State machine: available → reserved → delivered, exits `returned` (refund
        return-to-stock) and `invalidated` (supplier/staff pull).
      enum: [available, reserved, delivered, returned, invalidated]

    TextUploadItem:
      type: object
      additionalProperties: false
      required: [kind, value]
      properties:
        kind:
          type: string
          enum: [text]
        value:
          type: string
          minLength: 1
          maxLength: 1024

    FileUploadItem:
      type: object
      additionalProperties: false
      required: [kind, file_name, content_type, content_base64]
      properties:
        kind:
          type: string
          enum: [file]
        file_name: { type: string }
        content_type:
          type: string
          enum: [application/pdf, image/png, image/jpeg]
        content_base64:
          type: string
          contentEncoding: base64
          description: ≤ 200 KB decoded.

    InventoryUploadItem:
      oneOf:
        - $ref: "#/components/schemas/TextUploadItem"
        - $ref: "#/components/schemas/FileUploadItem"
      discriminator:
        propertyName: kind
        mapping:
          text: "#/components/schemas/TextUploadItem"
          file: "#/components/schemas/FileUploadItem"

    InventoryUploadRequest:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        mode:
          type: string
          enum: [strict, partial]
          default: strict
          description: >-
            strict = all-or-nothing (any invalid item → 422 / inventory_rejected, nothing
            stored). partial = valid items stored, invalid ones reported.
        items:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: "#/components/schemas/InventoryUploadItem" }

    InventoryUploadResult:
      type: object
      required: [accepted, rejected, item_ids, stock_available]
      properties:
        accepted: { type: integer }
        rejected:
          type: array
          items: { $ref: "#/components/schemas/RejectedInventoryItem" }
        item_ids:
          type: array
          description: ULIDs of stored items, in submission order (rejected indexes skipped).
          items: { $ref: "#/components/schemas/Ulid" }
        stock_available:
          type: integer
          description: The offer's `available` count after this upload.

    InventoryItem:
      type: object
      required:
        - id
        - offer_id
        - kind
        - state
        - value_preview
        - fingerprint
        - file_name
        - uploaded_at
        - delivered_order_id
        - state_changed_at
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        offer_id: { $ref: "#/components/schemas/Ulid" }
        kind:
          type: string
          enum: [text, file]
        state: { $ref: "#/components/schemas/InventoryState" }
        value_preview:
          type: string
          description: >-
            First 4 characters, remainder masked. The API never returns a full value after
            upload — every buyer reveal is audited.
        fingerprint:
          type: string
          description: "`sha256:` prefixed fingerprint of the exact value / file bytes — the per-offer duplicate key."
        file_name: { type: [string, "null"] }
        uploaded_at: { type: string, format: date-time }
        delivered_order_id:
          oneOf:
            - $ref: "#/components/schemas/Ulid"
            - type: "null"
        state_changed_at: { type: string, format: date-time }

    InventoryInvalidateRequest:
      type: object
      additionalProperties: false
      required: [item_ids]
      properties:
        item_ids:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/Ulid" }

    SkippedInvalidation:
      type: object
      required: [id, code, state]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        code: { $ref: "#/components/schemas/ItemCode" }
        state:
          $ref: "#/components/schemas/InventoryState"
          description: The item's current state (why it could not be invalidated).

    InventoryInvalidateResult:
      type: object
      required: [invalidated, skipped, stock_available]
      properties:
        invalidated: { type: integer }
        skipped:
          type: array
          items: { $ref: "#/components/schemas/SkippedInvalidation" }
        stock_available: { type: integer }

    OrderStatus:
      type: string
      description: >-
        Supplier-visible read-model mapping of the canonical order machine
        (02-supplier-api.md §6.6 / 01-data-model.md §3.3). `pending` covers
        pending_payment/authorized/under_review; `expired` covers expired/cancelled.
      enum: [pending, provisioning, completed, partially_completed, failed, expired, partially_refunded, refunded]

    OrderItemStatus:
      type: string
      description: Canonical item machine values (01-data-model.md §3.4), verbatim.
      enum: [reserved, provisioning, provisioned, delivered, failed, released, refunded]

    FulfilmentSource:
      type: string
      enum: [uploaded, declared, topup]

    OrderItemFulfilment:
      type: object
      description: >-
        `uploaded` carries `inventory_item_ids`; `declared` carries `reservation_id`;
        `topup` carries `operation_id`.
      required: [source]
      properties:
        source: { $ref: "#/components/schemas/FulfilmentSource" }
        reservation_id:
          oneOf:
            - $ref: "#/components/schemas/Ulid"
            - type: "null"
        inventory_item_ids:
          type: [array, "null"]
          items: { $ref: "#/components/schemas/Ulid" }
        operation_id:
          oneOf:
            - $ref: "#/components/schemas/Ulid"
            - type: "null"

    OrderItem:
      type: object
      required:
        - id
        - product_id
        - offer_id
        - external_ref
        - quantity
        - unit_price
        - gross
        - commission
        - net
        - status
        - fulfilment
        - delivered_at
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        product_id: { $ref: "#/components/schemas/Ulid" }
        offer_id: { $ref: "#/components/schemas/Ulid" }
        external_ref: { type: [string, "null"], maxLength: 64 }
        quantity: { type: integer, minimum: 1 }
        unit_price: { $ref: "#/components/schemas/Money" }
        gross: { $ref: "#/components/schemas/Money" }
        commission:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: "`max(round_half_up(gross × 0.085), 5)` per item."
        net: { $ref: "#/components/schemas/Money" }
        status: { $ref: "#/components/schemas/OrderItemStatus" }
        fulfilment: { $ref: "#/components/schemas/OrderItemFulfilment" }
        delivered_at: { type: [string, "null"], format: date-time }

    OrderTotals:
      type: object
      description: Totals cover **your** items only.
      required: [gross, commission, net]
      properties:
        gross: { $ref: "#/components/schemas/Money" }
        commission: { $ref: "#/components/schemas/Money" }
        net: { $ref: "#/components/schemas/Money" }

    Order:
      type: object
      description: >-
        Supplier-scoped view: only your items; no buyer PII beyond `buyer_country`.
      required:
        - id
        - number
        - status
        - buyer_country
        - currency
        - created_at
        - paid_at
        - completed_at
        - items
        - totals
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        number:
          type: string
          description: "`CK-` + the order ULID."
          examples: [CK-01JZY2H8K4M6N8P0Q2R4S6T8V0]
        status: { $ref: "#/components/schemas/OrderStatus" }
        buyer_country: { $ref: "#/components/schemas/CountryCode" }
        currency:
          type: string
          examples: [EUR]
        created_at: { type: string, format: date-time }
        paid_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        items:
          type: array
          items: { $ref: "#/components/schemas/OrderItem" }
        totals: { $ref: "#/components/schemas/OrderTotals" }

    Balance:
      type: object
      required: [currency, available, pending, updated_at]
      properties:
        currency: { type: string, examples: [EUR] }
        available: { $ref: "#/components/schemas/Money" }
        pending:
          allOf: [{ $ref: "#/components/schemas/Money" }]
          description: Net proceeds inside the holding period (7 days prod, 10 min sandbox).
        updated_at: { type: string, format: date-time }

    LedgerEntryType:
      type: string
      description: The closed entry_type enum from 01-data-model.md §2.7.
      enum: [sale, refund_reversal, invalidation_chargeback, payout, payout_reversal, adjustment]

    LedgerEntryStatus:
      type: string
      enum: [pending, settled]

    LedgerEntry:
      type: object
      description: >-
        Append-only. `sale` credits the net at delivery (pending until `available_at`);
        `refund_reversal`/`invalidation_chargeback` debit exactly the net of affected items;
        `payout`/`payout_reversal`/`adjustment` are signed accordingly (`adjustment` always
        carries `description`). Amounts can be negative.
      required:
        - id
        - type
        - amount
        - breakdown
        - status
        - available_at
        - order_id
        - order_item_id
        - description
        - created_at
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        type: { $ref: "#/components/schemas/LedgerEntryType" }
        amount: { $ref: "#/components/schemas/Money" }
        breakdown:
          type: [object, "null"]
          description: For `sale`/`refund_reversal`/`invalidation_chargeback` — gross and commission in minor units.
          properties:
            gross: { type: integer }
            commission: { type: integer }
        status: { $ref: "#/components/schemas/LedgerEntryStatus" }
        available_at: { type: [string, "null"], format: date-time }
        order_id:
          oneOf: [{ $ref: "#/components/schemas/Ulid" }, { type: "null" }]
        order_item_id:
          oneOf: [{ $ref: "#/components/schemas/Ulid" }, { type: "null" }]
        description: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    WebhookEventType:
      type: string
      description: Extensible — new event types may be added; tolerate unknown values.
      enum:
        - order.created
        - order.paid
        - order.expired
        - order.refunded
        - order.disputed
        - offer.deactivated
        - offer.stock_low
        - stock.invalidated
        - webhook.disabled

    WebhookSubscription:
      type: string
      description: An event type from the catalog, or `*` for everything.
      enum:
        - order.created
        - order.paid
        - order.expired
        - order.refunded
        - order.disputed
        - offer.deactivated
        - offer.stock_low
        - stock.invalidated
        - webhook.disabled
        - "*"

    WebhookEndpointStatus:
      type: string
      description: >-
        Canonical statuses (01-data-model.md §3.10). `paused` events are skipped, not buffered;
        re-enabling a `disabled` endpoint requires a passing test-fire.
      enum: [active, paused, disabled]

    WebhookEndpoint:
      type: object
      required: [id, url, events, description, status, consecutive_failures, disabled_at, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        url: { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookSubscription" }
        description: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/WebhookEndpointStatus" }
        consecutive_failures:
          type: integer
          description: Resets on any 2xx; 30 auto-disables the endpoint.
        disabled_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: "`whsec_` signing secret — shown ONLY in this creation response."

    WebhookEndpointCreateRequest:
      type: object
      additionalProperties: false
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: HTTPS, publicly resolvable, not a private/link-local IP.
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookSubscription" }
        description: { type: string }

    WebhookEndpointUpdateRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        url: { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookSubscription" }
        description: { type: string }
        status:
          $ref: "#/components/schemas/WebhookEndpointStatus"
          description: Re-enabling resets `consecutive_failures` to 0.

    WebhookTestRequest:
      type: object
      additionalProperties: false
      properties:
        event:
          $ref: "#/components/schemas/WebhookEventType"
          description: Defaults to `order.paid`.

    WebhookTestResult:
      type: object
      required: [delivery_id, event_type, response_status, duration_ms, succeeded]
      properties:
        delivery_id: { $ref: "#/components/schemas/Ulid" }
        event_type: { $ref: "#/components/schemas/WebhookEventType" }
        response_status: { type: [integer, "null"] }
        duration_ms: { type: integer }
        succeeded: { type: boolean }

    WebhookDeliveryStatus:
      type: string
      enum: [pending, succeeded, failed]

    WebhookDelivery:
      type: object
      description: One row per delivery **attempt**. Retention 30 days.
      required:
        - id
        - webhook_endpoint_id
        - event_id
        - event_type
        - attempt
        - status
        - response_status
        - duration_ms
        - error
        - next_attempt_at
        - created_at
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        webhook_endpoint_id: { $ref: "#/components/schemas/Ulid" }
        event_id: { $ref: "#/components/schemas/Ulid" }
        event_type: { $ref: "#/components/schemas/WebhookEventType" }
        attempt:
          type: integer
          minimum: 1
          maximum: 7
        status: { $ref: "#/components/schemas/WebhookDeliveryStatus" }
        response_status: { type: [integer, "null"] }
        duration_ms: { type: [integer, "null"] }
        error: { type: [string, "null"] }
        next_attempt_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookDeliveryDetail:
      allOf:
        - $ref: "#/components/schemas/WebhookDelivery"
        - type: object
          required: [request_body, response_body]
          properties:
            request_body:
              type: string
              description: The exact signed payload sent.
            response_body:
              type: [string, "null"]
              description: First 4 KB of the endpoint's response body.

    WebhookResendResult:
      type: object
      required: [delivery_id, status]
      properties:
        delivery_id:
          $ref: "#/components/schemas/Ulid"
          description: The **new** delivery attempt's id.
        status:
          type: string
          enum: [pending]

    WhoAmI:
      type: object
      required: [token, supplier, environment, rate_limits]
      properties:
        token:
          type: object
          required: [id, name, mode, scopes, created_at, last_used_at]
          properties:
            id: { $ref: "#/components/schemas/Ulid" }
            name: { type: string }
            mode:
              type: string
              enum: [live, test]
            scopes:
              type: array
              items:
                type: string
                enum:
                  - catalog:read
                  - pricing:read
                  - offers:read
                  - offers:write
                  - inventory:read
                  - inventory:write
                  - orders:read
                  - balance:read
                  - webhooks:manage
            created_at: { type: string, format: date-time }
            last_used_at: { type: [string, "null"], format: date-time }
        supplier:
          type: object
          required: [id, name, status]
          properties:
            id: { $ref: "#/components/schemas/Ulid" }
            name: { type: string }
            status:
              type: string
              description: Suspended suppliers get 403 / supplier_suspended everywhere except /whoami.
              enum: [active, suspended]
        environment:
          type: string
          enum: [production, sandbox]
        rate_limits:
          type: object
          description: Effective per-token limits per minute (reflects granted raises).
          required: [reads, writes, sync, bulk, simulate, test, ceilings]
          properties:
            reads: { type: integer }
            writes: { type: integer }
            sync: { type: integer }
            bulk: { type: integer }
            simulate: { type: integer }
            test: { type: integer }
            ceilings:
              type: object
              description: >-
                Additional ceilings (02 §3.3) checked after the per-token class limit.
                Reflects any granted per-supplier raise.
              required: [per_supplier]
              properties:
                per_supplier:
                  type: integer
                  description: Requests/min across all of the supplier's tokens.

    SandboxScenario:
      type: string
      enum: [paid, payment_failed, refund_after_delivery, dispute_after_delivery]

    SandboxOrderRequest:
      type: object
      additionalProperties: false
      required: [offer_id, buyer_country]
      properties:
        offer_id: { $ref: "#/components/schemas/Ulid" }
        quantity: { type: integer, minimum: 1, default: 1 }
        buyer_country: { $ref: "#/components/schemas/CountryCode" }
        scenario:
          $ref: "#/components/schemas/SandboxScenario"
          description: Defaults to `paid`.
        form_fields:
          type: object
          description: Required for top-up offers, forbidden otherwise. Values keyed by FormField `key`.
          additionalProperties: { type: string }

    RefundReason:
      type: string
      description: The canonical refunds.reason_code enum (01-data-model.md §2.5), string-identical.
      enum: [buyer_withdrawal, key_invalid, not_delivered, region_locked, provision_failed, capture_failed, fraud, dispute, goodwill, duplicate]

    Refund:
      type: object
      required: [id, order_item_ids, amount, reason, created_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        order_item_ids:
          type: array
          items: { $ref: "#/components/schemas/Ulid" }
        amount: { $ref: "#/components/schemas/Money" }
        reason: { $ref: "#/components/schemas/RefundReason" }
        created_at: { type: string, format: date-time }

    ReturnToStock:
      type: object
      required: [attempted, inventory_item_ids, declared_return_called]
      properties:
        attempted: { type: boolean }
        inventory_item_ids:
          type: array
          description: Uploaded items returned to `available` (not yet revealed by the buyer).
          items: { $ref: "#/components/schemas/Ulid" }
        declared_return_called:
          type: boolean
          description: Whether we called your `DELETE /orders/{id}/items`.

    DisputeReason:
      type: string
      enum: [key_invalid, not_delivered, region_locked, other]

    Dispute:
      type: object
      description: >-
        Informational — we are merchant of record (04 §9): buyers deal with our support; there
        is no seller-response window. An upheld claim arrives later as order.refunded.
      required: [id, order_item_id, kind, reason, message, opened_at]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        order_item_id: { $ref: "#/components/schemas/Ulid" }
        kind:
          type: string
          enum: [buyer_claim, card_dispute]
        reason: { $ref: "#/components/schemas/DisputeReason" }
        message:
          type: [string, "null"]
          maxLength: 500
          description: Buyer text.
        opened_at: { type: string, format: date-time }

    OfferDeactivationReason:
      type: string
      enum: [supplier_request, health_exhausted, admin_action, product_unlisted]

    EventEnvelope:
      type: object
      description: >-
        Common webhook envelope. `id` is the dedupe key. Ordering is not guaranteed —
        consume idempotently; trust the embedded `data` state over event arrival order.
      required: [id, type, mode, api_version, test_fire, created_at, data]
      properties:
        id: { $ref: "#/components/schemas/Ulid" }
        type: { $ref: "#/components/schemas/WebhookEventType" }
        mode:
          type: string
          enum: [live, test]
        api_version:
          type: string
          const: v1
        test_fire:
          type: boolean
          description: "`true` only for POST /webhooks/{id}/test deliveries."
        created_at: { type: string, format: date-time }
        data: { type: object }

    OrderCreatedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: order.created }
            data:
              type: object
              required: [order]
              properties:
                order: { $ref: "#/components/schemas/Order" }

    OrderPaidEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: order.paid }
            data:
              type: object
              required: [order]
              properties:
                order: { $ref: "#/components/schemas/Order" }

    OrderExpiredEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: order.expired }
            data:
              type: object
              required: [order]
              properties:
                order: { $ref: "#/components/schemas/Order" }

    OrderRefundedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: order.refunded }
            data:
              type: object
              required: [order, refund, ledger_entry_id, return_to_stock]
              properties:
                order: { $ref: "#/components/schemas/Order" }
                refund: { $ref: "#/components/schemas/Refund" }
                ledger_entry_id: { $ref: "#/components/schemas/Ulid" }
                return_to_stock: { $ref: "#/components/schemas/ReturnToStock" }

    OrderDisputedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: order.disputed }
            data:
              type: object
              required: [order, dispute]
              properties:
                order: { $ref: "#/components/schemas/Order" }
                dispute: { $ref: "#/components/schemas/Dispute" }

    OfferDeactivatedEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: offer.deactivated }
            data:
              type: object
              required: [offer_id, external_ref, product_id, reason, detail, health_score, deactivated_at]
              properties:
                offer_id: { $ref: "#/components/schemas/Ulid" }
                external_ref: { type: [string, "null"] }
                product_id: { $ref: "#/components/schemas/Ulid" }
                reason: { $ref: "#/components/schemas/OfferDeactivationReason" }
                detail: { type: [string, "null"] }
                health_score: { type: integer, minimum: 0, maximum: 100 }
                deactivated_at: { type: string, format: date-time }

    OfferStockLowEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: offer.stock_low }
            data:
              type: object
              required: [offer_id, external_ref, product_id, available, threshold]
              properties:
                offer_id: { $ref: "#/components/schemas/Ulid" }
                external_ref: { type: [string, "null"] }
                product_id: { $ref: "#/components/schemas/Ulid" }
                available: { type: integer }
                threshold: { type: integer }

    WebhookDisabledEvent:
      allOf:
        - $ref: "#/components/schemas/EventEnvelope"
        - type: object
          properties:
            type: { const: webhook.disabled }
            data:
              type: object
              required: [webhook_endpoint_id, url, reason, consecutive_failures, last_error, disabled_at]
              properties:
                webhook_endpoint_id: { $ref: "#/components/schemas/Ulid" }
                url: { type: string, format: uri }
                reason:
                  type: string
                  description: >-
                    `consecutive_failures` = 30 consecutive failed attempts; `gone` = your
                    endpoint answered 410.
                  enum: [consecutive_failures, gone]
                consecutive_failures: { type: integer }
                last_error:
                  type: object
                  required: [response_status, message]
                  properties:
                    response_status: { type: [integer, "null"] }
                    message: { type: string }
                disabled_at: { type: string, format: date-time }
