openapi: 3.1.0

info:
  title: cheap-keys Top-Up Fulfilment Contract
  version: 1.0.0
  summary: >-
    The validate → execute contract that SUPPLIERS implement for `topup` products 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.** For products of `type: topup` (virtual currency /
    player-account delivery). Signing, base URL configuration, and the healthcheck are
    exactly as in `declared-stock-supplier-contract.yaml` — top-up offers configure the
    same `fulfilment` block; `stock_mode` is `declared`, and **reservations are not used**
    for top-ups: `validate` replaces them as the pre-payment check.

    The product's `form_fields` (declared in the cheap-keys catalog) define what buyer
    inputs you receive; the platform enforces `required`/`pattern`/`max_length`/`options`
    **before** calling you, and passes values keyed by each field's `key`.

    ## Request signing

    Every call is signed with the offer's `cksec_` 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.
    Verify against the raw body with a constant-time comparison. Requests also carry
    `X-CK-Offer-Id` and `User-Agent: cheap-keys-fulfilment/1.0
    (+https://pay.mmokick.com/developers)`. Unsigned lookalike requests are attacks.

    ## Timing and health

    | Call | Timeout | Attempts | Window |
    |---|---|---|---|
    | `POST /topup/validate` | 10 s | 1 (no retry) | pre-payment; must be side-effect free |
    | `POST /topup/execute` | 30 s | 3, 10 s apart | completion hard window **15 min** |
    | `GET /topup/operations/{id}` | 10 s | poll every 10 s for the first minute, then every 30 s | until the 15-min window closes |
    | `GET /healthcheck` | 5 s | 1 | every 5 min active / 10 min cooldown |

    Validate timeout/5xx blocks checkout for your offer and costs **−2 health**. Execute
    failed/timed out after payment with no resolvable status by the 15-min window →
    treated as `failed`: the buyer is auto-refunded and your offer takes **−25 health +
    cooldown**. If you actually delivered but couldn't tell us, reconcile via the Supplier
    API `GET /orders` and support — this is why `202` + the status endpoint is the safer
    pattern for slow upstreams.

    **Idempotency contract: never deliver twice for the same `operation_id`.**

    ## Changelog

    ### 2026-07-11 — 1.0.0

    - Initial published contract: side-effect-free validate (HTTP 200 for both valid and
      invalid outcomes), idempotent execute (201 completed / 200 replay / 202 processing /
      failed status), operation status poll, shared healthcheck.

x-changelog:
  - date: "2026-07-11"
    version: "1.0.0"
    changes:
      - type: added
        summary: Initial published contract (validate, execute, operation status, healthcheck).

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. Shared with the declared-stock contract.
    variables:
      fulfilment_base_url:
        default: https://api.goldsupplier.example/ck
        description: Set per offer via the cheap-keys Supplier API.

tags:
  - name: Top-up
    description: Validate buyer account details, then execute the top-up after payment.
  - name: Health
    description: Liveness probing — shared with the declared-stock contract.

paths:

  /topup/validate:
    post:
      operationId: validateTopup
      tags: [Top-up]
      summary: Pre-payment check of buyer form_fields — REQUIRED, side-effect free
      description: |
        Called at checkout when the buyer has filled the product's `form_fields` (we have
        already enforced `required`/`pattern`/`options`). Timeout **10 s**, no retry.
        **Must be side-effect free.**

        Both outcomes are HTTP `200` — the *validation* ran fine either way. On
        `valid: true`, echo `operation_id` and include `account_label`, shown to the buyer
        for confirmation ("is this you?"). On `valid: false`, include a machine `code`
        (mapped to buyer-facing field errors) and a human `message`.

        Timeout/5xx blocks checkout for your offer and costs −2 health.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ValidateRequest" }
            examples:
              gold-topup:
                value:
                  operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                  offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                  external_ref: SKU-MMO-GOLD-1000
                  product_id: 01JZT5B7XCDE2FGH4JK6MN8PQ2
                  quantity: 1
                  buyer_country: FR
                  form_fields: { user_id: "51231234", server_id: "2001" }
      responses:
        "200":
          description: Validation outcome — valid or invalid (still HTTP 200).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidateResponse" }
              examples:
                valid:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    valid: true
                    account_label: DragonSlayer99 (EU West)
                invalid:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    valid: false
                    code: invalid_user_id
                    message: No account with that ID on EU West.
        default:
          description: Timeout/5xx blocks checkout for your offer and costs −2 health.

  /topup/execute:
    post:
      operationId: executeTopup
      tags: [Top-up]
      summary: Perform the top-up — REQUIRED, idempotent on operation_id
      description: |
        Called after payment capture, with the same `operation_id` from the successful
        validate. **Idempotent on `operation_id`** — "never deliver twice for the same
        operation" is the contract. Timeout **30 s** per attempt, 3 attempts 10 s apart,
        completion hard window **15 min**.

        Responses:
        - `201` `status: completed` — done; `receipt_ref` (your transaction id, ≤ 128
          chars) is stored on the Delivery and shown to the buyer.
        - `200` — idempotent replay of an already-executed operation (the original body).
        - `202` `status: processing` — async; we poll `GET /topup/operations/{id}` until
          `completed`/`failed`, ≤ 15 min. The safer pattern for slow upstreams.
        - `200`/`201` `status: failed` + `code`/`message` — definitive failure; we
          auto-refund the buyer and your offer takes −25 health + cooldown.
      parameters:
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExecuteRequest" }
            examples:
              gold-topup:
                value:
                  operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                  order_id: 01JZY2H8K4M6N8P0Q2R4S6T8V0
                  offer_id: 01JZWX5N3PQRS7TV9WXY2Z4A6B
                  external_ref: SKU-MMO-GOLD-1000
                  quantity: 1
                  form_fields: { user_id: "51231234", server_id: "2001" }
      responses:
        "201":
          description: "Completed (or, with `status: failed`, a definitive failure on first execution)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OperationResult" }
              examples:
                completed:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: completed
                    receipt_ref: TX-889123
        "200":
          description: >-
            Idempotent replay — the original result body of an already-executed operation
            (completed or failed).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OperationResult" }
              examples:
                replay:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: completed
                    receipt_ref: TX-889123
                failed:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: failed
                    code: account_banned
                    message: The target account is banned.
        "202":
          description: Async — we poll the status endpoint until completed/failed, ≤ 15 min.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OperationProcessing" }
              examples:
                processing:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: processing
        default:
          description: >-
            Timeout/5xx on all attempts with no resolvable status by the 15-min window →
            treated as failed (auto-refund, −25 health + cooldown).

  /topup/operations/{operation_id}:
    get:
      operationId: getTopupOperation
      tags: [Top-up]
      summary: Operation status poll — required only if you ever return 202 from execute
      description: |
        Poll cadence: every 10 s for the first minute, then every 30 s until the 15-min
        window closes. Return the idempotent final result once known.
      parameters:
        - $ref: "#/components/parameters/OperationId"
        - $ref: "#/components/parameters/XCKSignature"
        - $ref: "#/components/parameters/XCKTimestamp"
        - $ref: "#/components/parameters/XCKOfferId"
      responses:
        "200":
          description: Current operation status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OperationStatus" }
              examples:
                completed:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: completed
                    receipt_ref: TX-889123
                processing:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: processing
                failed:
                  value:
                    operation_id: 01JZY2G9X1C3E5G7J9M2P4R6T8
                    status: failed
                    code: account_banned
                    message: The target account is banned.
        default:
          description: Errors/timeouts consume poll attempts within the 15-min window.

  /healthcheck:
    get:
      operationId: healthcheck
      tags: [Health]
      summary: Liveness probe — REQUIRED (shared with the declared-stock contract)
      description: >-
        Respond `200` `{"status": "ok"}` within 5 s. No side effects. 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. 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.
      schema: { $ref: "#/components/schemas/Ulid" }
    OperationId:
      name: operation_id
      in: path
      required: true
      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: [01JZY2G9X1C3E5G7J9M2P4R6T8]

    FormFieldValues:
      type: object
      description: >-
        Buyer inputs keyed by the product's FormField `key` values. Already validated by
        the platform against required/pattern/max_length/options. Values are always
        strings (including `number` fields and `select` option values).
      additionalProperties: { type: string }
      examples:
        - { user_id: "51231234", server_id: "2001" }

    ValidateRequest:
      type: object
      required:
        - operation_id
        - offer_id
        - external_ref
        - product_id
        - quantity
        - buyer_country
        - form_fields
      properties:
        operation_id:
          $ref: "#/components/schemas/Ulid"
          description: Echo this in your response; the same id is used at execute time.
        offer_id: { $ref: "#/components/schemas/Ulid" }
        external_ref: { type: [string, "null"], maxLength: 64 }
        product_id: { $ref: "#/components/schemas/Ulid" }
        quantity: { type: integer, minimum: 1 }
        buyer_country:
          type: string
          pattern: "^[A-Z]{2}$"
        form_fields: { $ref: "#/components/schemas/FormFieldValues" }

    ValidationFailureCode:
      type: string
      description: Mapped to buyer-facing field errors.
      enum: [invalid_user_id, invalid_server, account_ineligible, limit_exceeded, other]

    ValidateResponse:
      oneOf:
        - $ref: "#/components/schemas/ValidateValid"
        - $ref: "#/components/schemas/ValidateInvalid"

    ValidateValid:
      type: object
      required: [operation_id, valid]
      properties:
        operation_id: { $ref: "#/components/schemas/Ulid" }
        valid:
          type: boolean
          enum: [true]
        account_label:
          type: string
          description: Shown to the buyer for confirmation — "is this you?".

    ValidateInvalid:
      type: object
      required: [operation_id, valid, code]
      properties:
        operation_id: { $ref: "#/components/schemas/Ulid" }
        valid:
          type: boolean
          enum: [false]
        code: { $ref: "#/components/schemas/ValidationFailureCode" }
        message: { type: string }

    ExecuteRequest:
      type: object
      required:
        - operation_id
        - order_id
        - offer_id
        - external_ref
        - quantity
        - form_fields
      properties:
        operation_id:
          $ref: "#/components/schemas/Ulid"
          description: Same id as the successful validate — your idempotency key.
        order_id: { $ref: "#/components/schemas/Ulid" }
        offer_id: { $ref: "#/components/schemas/Ulid" }
        external_ref: { type: [string, "null"], maxLength: 64 }
        quantity: { type: integer, minimum: 1 }
        form_fields: { $ref: "#/components/schemas/FormFieldValues" }

    ExecutionFailureCode:
      type: string
      description: >-
        Machine reason for a definitive failure. Extensible — supply your closest match;
        unknown codes are treated as `other`.
      enum: [account_banned, invalid_user_id, invalid_server, account_ineligible, limit_exceeded, other]

    OperationCompleted:
      type: object
      required: [operation_id, status, receipt_ref]
      properties:
        operation_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [completed]
        receipt_ref:
          type: string
          maxLength: 128
          description: Your transaction id — stored on the Delivery and shown to the buyer.

    OperationFailed:
      type: object
      required: [operation_id, status, code]
      properties:
        operation_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [failed]
        code: { $ref: "#/components/schemas/ExecutionFailureCode" }
        message: { type: string }

    OperationProcessing:
      type: object
      required: [operation_id, status]
      properties:
        operation_id: { $ref: "#/components/schemas/Ulid" }
        status:
          type: string
          enum: [processing]

    OperationResult:
      oneOf:
        - $ref: "#/components/schemas/OperationCompleted"
        - $ref: "#/components/schemas/OperationFailed"

    OperationStatus:
      oneOf:
        - $ref: "#/components/schemas/OperationProcessing"
        - $ref: "#/components/schemas/OperationCompleted"
        - $ref: "#/components/schemas/OperationFailed"

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