openapi: 3.1.0

info:
  title: cheap-keys Declared-Stock Fulfilment Contract
  version: 1.0.0
  summary: >-
    The reservation → order → items contract that SUPPLIERS implement and cheap-keys
    calls. You host these endpoints; we are the client.
  contact:
    name: cheap-keys API support
    email: "api@pay.mmokick.com"
    url: "https://pay.mmokick.com/developers"
  description: |
    **Direction: we call you.** This document describes the HTTP endpoints a supplier must
    host to sell with `stock_mode: declared` — keys never leave you until sold. Configure
    the base URL as `fulfilment.base_url` on your offer in the cheap-keys Supplier API
    (`supplier-api.yaml`); all paths below are relative to it. The top-up contract
    (`topup-contract.yaml`) shares the same base URL, signing scheme, and healthcheck.

    ## Request signing (verify it!)

    Every call we make is signed with the offer's `cksec_` secret (returned once at offer
    creation, rotatable via `POST /offers/{offer_id}/fulfilment/rotate-secret`):

    ```
    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.
    - For requests without a body (GET/DELETE), the raw body is the empty string.
    - Compare with a constant-time comparison, against the **raw body** — before any JSON
      decode/re-encode.
    - The `v1=` prefix versions the scheme; future schemes ship as `v2=` alongside `v1=`
      (comma-separated), never replacing it silently.
    - **Unsigned lookalike requests are attacks. Reject them.** Our egress IPs are not
      published; authenticate by signature only.

    Official test vector:

    ```
    secret:         whsec_9a41c1f39b6a4f16bd1e2f7c8d3a5e70
    timestamp:      1767225600000
    body:           {"hello":"cheap-keys"}
    signed_payload: 1767225600000.{"hello":"cheap-keys"}
    signature:      8f82cbf191ac01a16c7801608cdcf53f4994664db9e5319fc79fea663a444d7a
    ```

    Every request also carries `X-CK-Offer-Id` and
    `User-Agent: cheap-keys-fulfilment/1.0 (+https://pay.mmokick.com/developers)`.

    ## SLA (published, enforced — gates your offer's health)

    | Step | Timeout per attempt | Attempts | Hard deadline |
    |---|---|---|---|
    | `POST /reservations` | 10 s | 2 (retry after 2 s) | — buyer is waiting at checkout |
    | Reservation TTL | — | — | **15 min** — honor `expires_at` |
    | `POST /orders` | 10 s | 3, 5 s apart | starts at payment **authorization** (authorize → provision → capture, 04 §4) |
    | Items ready (`GET …/items`) | 10 s per poll | poll at +0 s, +5 s, +15 s, +30 s, then every 60 s | **allocation window: 10 min** from first `POST /orders` attempt |
    | `DELETE /orders/{id}/items` | 10 s | 3 over 1 h | best-effort |
    | `GET /healthcheck` | 5 s | 1 | probed every 5 min while active; every 10 min in cooldown |

    Target: items ready within **60 s** of `POST /orders` (earns bonus health). Miss the
    10-minute allocation window and we cancel your item (reroute to another supplier or
    refund the buyer), send `DELETE /orders/{id}/items`, and you take a cooldown. You
    **must not** deliver after receiving that DELETE.

    ## Health effects (published math)

    Your offer's health starts at 100; routing requires ≥ 70.

    | Event | Effect |
    |---|---|
    | Delivered order, items ready ≤ 60 s | +2 (cap 100) |
    | Delivered order, items ready ≤ 10 min | +1 (cap 100) |
    | Reservation rejected (`out_of_stock` / `region_blocked`) | −5 |
    | Reservation timeout / 5xx / malformed response | −8 |
    | `POST /orders` failed after all retries | −25 + cooldown |
    | Allocation window (10 min) missed | −25 + cooldown |
    | 3 consecutive healthcheck failures | cooldown |
    | Trailing-60-min reservation failure rate ≥ 40% with ≥ 5 attempts | cooldown |

    Cooldown = 2 hours excluded from routing; during cooldown we probe `/healthcheck`
    every 10 min; it extends in 2 h increments until the latest probe passes; on exit,
    health is raised to 70 if below. 24 h continuously in cooldown → your offer is
    auto-deactivated (`offer.deactivated`, reason `health_exhausted`). Reactivation is
    self-service: fix your endpoint, `POST /offers/{id}/activate` (inline healthcheck must
    pass); health restarts at 70. There is **no unfulfilled-sale fee** — routing exclusion
    is the sole penalty.

    ## Happy-path sequence

    ```
    buyer starts checkout →
      us:  POST {base}/reservations        (at checkout start, TTL 15 min)
      you: 201
      us:  webhook order.created           (to your webhook endpoints)
      [payment AUTHORIZED — funds held, not yet captured]
      us:  POST {base}/orders              (idempotent on order ULID)
      you: 201 accepted
      us:  GET {base}/orders/{id}/items    (+0 s … until ready, ≤ 10 min)
      you: 200 ready + items
      [payment CAPTURED — only now is the buyer charged]
      us:  webhook order.paid              (your items were already provisioned)
      us:  encrypt, deliver to buyer, order → completed, ledger sale entry
    ```

    If provisioning fails, the authorization is voided — the buyer is never charged for
    items you didn't deliver, and no order.paid fires for them.

    ## Sandbox

    `POST /sandbox/orders` on the sandbox Supplier API drives this exact contract against
    your endpoints with real signatures — reach a delivered test order without talking to
    a human. Sandbox targets may use any HTTPS port; private IPs are still refused (use a
    tunnel for local dev).

    ## Changelog

    ### 2026-07-11 — 1.0.0

    - Initial published contract: reservations (TTL 15 min, early release), idempotent
      orders (409 duplicate = same body, 410 expired, 202 pending stock), items handover
      (text/file kinds), best-effort return-to-stock, healthcheck. SLA and health math
      published.

x-changelog:
  - date: "2026-07-11"
    version: "1.0.0"
    changes:
      - type: added
        summary: Initial published contract (reservations, orders, items, return-to-stock, healthcheck).
      - type: added
        summary: SLA table and health/cooldown math published.

servers:
  - url: "{fulfilment_base_url}"
    description: >-
      The `fulfilment.base_url` configured on your offer — HTTPS, publicly resolvable, not
      a private/link-local IP, ≤ 2,048 chars.
    variables:
      fulfilment_base_url:
        default: https://api.keyforge.example/ck
        description: Set per offer via the cheap-keys Supplier API.

tags:
  - name: Reservations
    description: Hold stock while a buyer checks out. TTL 15 minutes.
  - name: Orders
    description: Convert reservations to sales and hand over deliverables.
  - name: Health
    description: Liveness probing — drives routing eligibility and cooldown exit.

paths:

  /reservations:
    post:
      operationId: createReservation
      tags: [Reservations]
      summary: Hold stock for a checkout (TTL 15 min) — REQUIRED
      description: |
        We call this when a buyer starts paying for your offer — the buyer is waiting, so
        answer fast (10 s timeout, 2 attempts 2 s apart). Hold `quantity` units until
        `expires_at` (15 minutes). Treat `reservation_id` as your idempotency key: if you
        already hold it, answer `200` with the same body instead of double-holding.

        Rejections are signalled with `409` + a machine `code`. We pre-filter buyers by the
        product's country lists, so `region_blocked` should be rare. Any other outcome
        (timeout, 5xx, malformed body) is a reservation failure: −8 health and the sale
        routes to the next supplier.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReservationRequest" }
            examples:
              single-unit:
                value:
                  reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                  offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                  external_ref: SKU-STARFORGE-GLOBAL
                  product_id: 01JZT4S9GVW2X5YZ7A9BCD3EFG
                  quantity: 1
                  buyer_country: FR
                  expires_at: "2026-07-11T14:17:58Z"
      responses:
        "201":
          description: Stock held until `expires_at`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReservationResponse" }
              examples:
                held:
                  value:
                    reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                    expires_at: "2026-07-11T14:17:58Z"
        "200":
          description: Idempotent replay — you already hold this `reservation_id`. Same body as the original `201`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReservationResponse" }
              examples:
                replay:
                  value:
                    reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                    expires_at: "2026-07-11T14:17:58Z"
        "409":
          description: >-
            Cannot hold. `out_of_stock` — we route to the next supplier (−5 health).
            `region_blocked` — you refuse this `buyer_country` (−5 health; rare, we
            pre-filter by catalog country lists).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReservationRejection" }
              examples:
                out-of-stock:
                  value: { code: out_of_stock }
                region-blocked:
                  value: { code: region_blocked }
        default:
          description: >-
            Anything else (timeout, 5xx, other 4xx, malformed body) = reservation failure →
            −8 health, next supplier.

  /reservations/{reservation_id}:
    delete:
      operationId: releaseReservation
      tags: [Reservations]
      summary: Early release (checkout abandoned) — OPTIONAL
      description: |
        Sent when checkout is abandoned or payment fails before authorization. Respond `204`
        (or `404` if already expired — we ignore either way). If you don't implement this
        endpoint, holds simply expire at TTL; that is acceptable.
      parameters:
        - $ref: "#/components/parameters/ReservationId"
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      responses:
        "204":
          description: Hold released.
        "404":
          description: Already expired/unknown — treated the same as 204.
        default:
          description: Ignored — release is best-effort; TTL expiry is the backstop.

  /orders:
    post:
      operationId: createOrder
      tags: [Orders]
      summary: Convert a reservation to a sale — REQUIRED, idempotent on order_id
      description: |
        Sent at payment authorization — funds held; capture follows once items are ready
        (10 s timeout, 3 attempts 5 s apart). **Idempotent on
        `order_id`** — never create a second sale for the same `order_id`. On a duplicate
        (our retry), respond `409` with **the exact same body as your original response**;
        we treat that as success.

        `410` means the reservation TTL'd out before we converted: we attempt one fresh
        reservation+order cycle, then fail over. `202` means you accepted the sale but
        stock is momentarily unavailable — the 10-minute allocation window still applies
        from our **first** `POST /orders` attempt.

        Failure after all retries: −25 health + cooldown, and we reroute or refund.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrderRequest" }
            examples:
              convert:
                value:
                  order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                  reservation_id: 01JZY2G7Q3W5E7R9T1Y3A5S7DF
                  offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                  external_ref: SKU-STARFORGE-GLOBAL
                  quantity: 1
                  unit_price: { amount: 1999, currency: EUR }
                  buyer_country: FR
      responses:
        "201":
          description: Sale recorded; we start polling for items.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderAccepted" }
              examples:
                accepted:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: accepted
        "202":
          description: >-
            Accepted but stock momentarily unavailable (`pending_stock`) — the 10-min
            allocation window still applies.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderPendingStock" }
              examples:
                pending-stock:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: pending_stock
        "409":
          description: >-
            Duplicate `order_id` (our retry) — respond with **the exact same body as your
            original response**. Treated as success. Never create a second sale.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OrderAccepted"
                  - $ref: "#/components/schemas/OrderPendingStock"
              examples:
                duplicate:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: accepted
        "410":
          description: >-
            Reservation TTL'd out (`reservation_expired`) — we attempt one fresh
            reservation+order cycle, then fail over.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReservationExpired" }
              examples:
                expired:
                  value: { code: reservation_expired }
        default:
          description: >-
            Anything else, on all retries, counts as order failure → −25 health + cooldown;
            we reroute to another supplier or refund the buyer.

  /orders/{order_id}/items:
    get:
      operationId: getOrderItems
      tags: [Orders]
      summary: Hand over the deliverables — REQUIRED
      description: |
        We poll on the SLA schedule (+0 s, +5 s, +15 s, +30 s, then every 60 s; 10 s
        timeout per poll) until you answer `status: "ready"` — within the 10-minute
        allocation window. While still allocating, answer `status: "pending"` with an
        empty `items` array.

        `items` count must equal the ordered `quantity`. Once you return
        `status: "ready"`, items are considered delivered — return the **same idempotent
        payload** on re-poll; never regenerate values.
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      responses:
        "200":
          description: Ready (with items) or still pending (empty items).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderItemsResponse" }
              examples:
                ready:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: ready
                    items:
                      - { kind: text, value: AAAA-BBBB-CCCC-DDDD }
                pending:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: pending
                    items: []
                ready-file:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    status: ready
                    items:
                      - kind: file
                        file_name: giftcard-0042.pdf
                        content_type: application/pdf
                        content_base64: JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoK…
        default:
          description: >-
            Errors/timeouts just consume poll attempts; if no `ready` arrives within the
            10-minute window we cancel the item (−25 health + cooldown) and send the
            DELETE below.
    delete:
      operationId: returnOrderItems
      tags: [Orders]
      summary: Return items to stock on refund / cancellation — OPTIONAL
      description: |
        Sent on refund before buyer reveal, or on allocation-window cancellation
        (best-effort: 10 s timeout, 3 attempts over 1 h). Respond `200` with the returned
        count — the keys are yours to resell. Respond `405` if you don't support
        return-to-stock; we proceed regardless (you keep the sale's refund debit either
        way). **You must not deliver after receiving this DELETE.**
      parameters:
        - $ref: "#/components/parameters/OrderId"
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      responses:
        "200":
          description: Items returned to your sellable stock.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReturnResult" }
              examples:
                returned:
                  value:
                    order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                    returned: 1
        "405":
          description: Return-to-stock unsupported — acceptable; we proceed regardless.
        default:
          description: Ignored after 3 attempts — the call is best-effort.

  /healthcheck:
    get:
      operationId: healthcheck
      tags: [Health]
      summary: Liveness probe — REQUIRED
      description: |
        Respond `200` `{"status": "ok"}` within **5 s**. No side effects. Probed every
        5 minutes while your offer is active, every 10 minutes during cooldown, and inline
        on offer activation and fulfilment URL changes. 3 consecutive failures → cooldown.
        We still sign the request; you may skip verification here.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      responses:
        "200":
          description: Alive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthcheckResponse" }
              examples:
                ok:
                  value: { status: ok }
        default:
          description: Any non-200 (or >5 s) counts as a failed probe.

components:

  parameters:
    XCKSignature:
      name: X-CK-Signature
      in: header
      required: true
      description: >-
        `v1=hex(HMAC-SHA256(offer cksec_ secret, "<X-CK-Timestamp>.<raw body>"))`.
        Verify with a constant-time comparison against the raw body (empty string for
        bodyless requests). Reject on mismatch.
      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]
    XCKOfferId:
      name: X-CK-Offer-Id
      in: header
      required: true
      description: The cheap-keys offer ULID this call concerns — selects which secret to verify with.
      schema: { $ref: "#/components/schemas/Ulid" }
    ReservationId:
      name: reservation_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/Ulid" }
    OrderId:
      name: order_id
      in: path
      required: true
      description: The cheap-keys order ULID (your idempotency key for the sale).
      schema: { $ref: "#/components/schemas/Ulid" }

  schemas:

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

    Money:
      type: object
      required: [amount, currency]
      properties:
        amount:
          type: integer
          description: Integer minor units (cents). Never floats, never strings.
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
          description: "ISO 4217. v1: always EUR."
          examples: [EUR]

    ReservationRequest:
      type: object
      description: Sent by cheap-keys when a buyer starts paying for your offer.
      required:
        - reservation_id
        - offer_id
        - external_ref
        - product_id
        - quantity
        - buyer_country
        - expires_at
      properties:
        reservation_id:
          $ref: "#/components/schemas/Ulid"
          description: Our reservation ULID — your idempotency key for the hold.
        offer_id: { $ref: "#/components/schemas/Ulid" }
        external_ref:
          type: [string, "null"]
          maxLength: 64
          description: Your own SKU reference, echoed back from your offer.
        product_id: { $ref: "#/components/schemas/Ulid" }
        quantity: { type: integer, minimum: 1 }
        buyer_country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO 3166-1 alpha-2. Pre-filtered against the product's country lists.
        expires_at:
          type: string
          format: date-time
          description: Hold deadline — 15 minutes from issue. Honor it.

    ReservationResponse:
      type: object
      required: [reservation_id, expires_at]
      properties:
        reservation_id: { $ref: "#/components/schemas/Ulid" }
        expires_at:
          type: string
          format: date-time
          description: Echo the requested `expires_at`.

    ReservationRejection:
      type: object
      required: [code]
      properties:
        code:
          type: string
          enum: [out_of_stock, region_blocked]

    OrderRequest:
      type: object
      description: Sent by cheap-keys at payment authorization (capture follows provisioning).
      required:
        - order_id
        - reservation_id
        - offer_id
        - external_ref
        - quantity
        - unit_price
        - buyer_country
      properties:
        order_id:
          $ref: "#/components/schemas/Ulid"
          description: Our order ULID — idempotency key; never create a second sale for it.
        reservation_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" }
        buyer_country:
          type: string
          pattern: "^[A-Z]{2}$"

    OrderAccepted:
      type: object
      required: [order_id, status]
      properties:
        order_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [accepted]

    OrderPendingStock:
      type: object
      required: [order_id, status]
      properties:
        order_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [pending_stock]

    ReservationExpired:
      type: object
      required: [code]
      properties:
        code:
          type: string
          enum: [reservation_expired]

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

    FileItem:
      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.

    DeliverableItem:
      oneOf:
        - $ref: "#/components/schemas/TextItem"
        - $ref: "#/components/schemas/FileItem"
      discriminator:
        propertyName: kind
        mapping:
          text: "#/components/schemas/TextItem"
          file: "#/components/schemas/FileItem"

    OrderItemsResponse:
      type: object
      description: >-
        While allocating, `status: pending` with empty `items`. Once `ready`, `items`
        count must equal the ordered quantity, and the payload must be idempotent on
        re-poll — never regenerate values.
      required: [order_id, status, items]
      properties:
        order_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [ready, pending]
        items:
          type: array
          items: { $ref: "#/components/schemas/DeliverableItem" }

    ReturnResult:
      type: object
      required: [order_id, returned]
      properties:
        order_id: { $ref: "#/components/schemas/Ulid" }
        returned:
          type: integer
          minimum: 0
          description: Number of items returned to your sellable stock.

    HealthcheckResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]
