> For the complete documentation index, see [llms.txt](https://series-1.gitbook.io/rundot-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://series-1.gitbook.io/rundot-docs/v5.21.0/readme/shop.md).

# Shop API

Browse your game's shop catalog from within your H5 game. The catalog is defined in your game's server config and uploaded via the existing server config pipeline.

## Overview

The shop system uses your game's **server config** as the source of truth:

1. Define your shop catalog in `.rundot/shop.config.json`
2. Upload via the standard CLI config upload (`venus game upload-server`)
3. The SDK reads the catalog at runtime via `RundotGameAPI.shop`

Each config upload creates a new immutable version. The storefront response includes a `configId` that pins the catalog to a specific version.

## Config Structure

### Recommended: `.rundot/shop.config.json`

Place your shop config at `.rundot/shop.config.json`. The file contains the shop configuration **directly** (no `shop` wrapping key):

```json
{
  "items": [
    {
      "itemId": "speed_boost",
      "name": "Speed Boost",
      "description": "Double movement speed for 60 seconds",
      "category": "consumable",
      "price": { "type": "bucks", "value": "100" },
      "entitlements": [
        { "entitlementId": "speed_boost_effect", "quantity": 1, "consumable": true }
      ],
      "assets": { "icon": "speed_icon.png" },
      "unique": false,
      "active": true,
      "regions": [],
      "refundEligible": true,
      "refundWindowHours": 24,
      "tags": ["boost"],
      "sortOrder": 1,
      "releasedAt": null,
      "expiresAt": null
    }
  ],
  "sales": [
    {
      "saleId": "launch_sale",
      "targetId": "speed_boost",
      "discountType": "percentage",
      "discountValue": 25,
      "regions": [],
      "startsAt": 0,
      "endsAt": 4102444800000,
      "active": true
    },
    {
      "saleId": "episode_sale",
      "targetId": "episodes",
      "targetType": "collection",
      "discountType": "percentage",
      "discountValue": 20,
      "regions": [],
      "startsAt": 0,
      "endsAt": 4102444800000,
      "active": true,
      "itemFilter": { "tags": ["premium"] }
    }
  ],
  "collections": [
    {
      "collectionId": "episodes",
      "price": { "type": "bucks", "value": "50" },
      "entitlement": { "consumable": false },
      "refundEligible": true,
      "refundWindowHours": 24,
      "items": [
        {
          "itemId": "ep-premium-1",
          "tags": ["premium"],
          "priceOverride": { "type": "bucks", "value": "100" }
        }
      ]
    }
  ]
}
```

> Commit `.rundot/` to your repo: it's project config like `package.json`, not a build artifact. It's env-agnostic and takes priority over legacy `config.{local,staging}.json` for any system it defines. `game.config.prod.json` is a separate file for local CLI metadata (`gameId`, `relativePathToDistFolder`); shop config does not go there.

### Also supported: legacy `config.json`

Add a `shop` key to your project's `config.json` (alongside `simulation`, etc.). Legacy layouts keep working indefinitely; run `rundot migrate-config` (with `--dry-run` to preview) to move them into `.rundot/` automatically.

<details>

<summary>Legacy <code>config.json</code></summary>

```json
{
  "simulation": { ... },
  "shop": {
    "items": [ ... ],
    "sales": [ ... ],
    "collections": [ ... ]
  }
}
```

</details>

## Item Fields

| Field               | Type      | Required | Default | Description                                         |
| ------------------- | --------- | -------- | ------- | --------------------------------------------------- |
| `itemId`            | string    | Yes      | -       | Unique identifier within the game                   |
| `name`              | string    | Yes      | -       | Display name                                        |
| `description`       | string    | Yes      | -       | Display description                                 |
| `category`          | enum      | Yes      | -       | `consumable`, `non_consumable`, or `time_bound`     |
| `price`             | object    | Yes      | -       | `{ type, value }`: see Price Structure              |
| `entitlements`      | array     | Yes      | -       | What the player receives: see Entitlements          |
| `assets`            | object    | No       | `{}`    | `{ thumbnail?, banner?, icon? }`: image identifiers |
| `unique`            | boolean   | No       | `false` | If true, player can only own one                    |
| `active`            | boolean   | No       | `false` | Whether the item is visible/purchasable             |
| `regions`           | string\[] | No       | `[]`    | Region codes; empty = all regions                   |
| `refundEligible`    | boolean   | No       | `true`  | Whether refunds are allowed                         |
| `refundWindowHours` | number    | No       | `24`    | Hours after purchase to allow refund                |
| `tags`              | string\[] | No       | `[]`    | Arbitrary tags for filtering                        |
| `sortOrder`         | number    | No       | `0`     | Display sort order                                  |
| `releasedAt`        | number    | No       | `null`  | Release timestamp (ms since epoch)                  |
| `expiresAt`         | number    | No       | `null`  | Expiry timestamp (ms since epoch)                   |

## Sale Fields

Sales are separate objects that reference items or collections by ID. Items don't need modification to go on sale, and multiple sales can be scheduled in advance.

| Field           | Type      | Required | Description                                                                                                   |
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `saleId`        | string    | Yes      | Unique identifier for the sale                                                                                |
| `targetId`      | string    | Yes      | `itemId` (when targeting items) or `collectionId` (when targeting collections)                                |
| `targetType`    | enum      | No       | `item` (default) or `collection`                                                                              |
| `discountType`  | enum      | Yes      | `percentage` or `fixed_price`                                                                                 |
| `discountValue` | number    | Yes      | Percentage (0–100) when `discountType` is `percentage`                                                        |
| `discountPrice` | object    | No       | Override price `{ type, value }` when `discountType` is `fixed_price`                                         |
| `regions`       | string\[] | No       | Region codes; empty = all regions                                                                             |
| `startsAt`      | number    | Yes      | Start timestamp (ms since epoch)                                                                              |
| `endsAt`        | number    | Yes      | End timestamp (ms since epoch)                                                                                |
| `active`        | boolean   | Yes      | Whether the sale is active                                                                                    |
| `itemFilter`    | object    | No       | Only for collection sales. Narrows which items the sale applies to: `{ tags?: string[], itemIds?: string[] }` |

When `targetType` is `collection`, the sale applies to all items in the collection by default. Use `itemFilter` to narrow it, for example, `{ "tags": ["premium"] }` only discounts items whose override entry has the `"premium"` tag. Items without overrides (or without matching tags) are unaffected by filtered sales.

## Price Structure

```json
{ "type": "bucks", "value": "100" }
```

| Price Type        | Description                                    |
| ----------------- | ---------------------------------------------- |
| `bucks`           | RunBucks (platform hard currency)              |
| `direct_purchase` | Direct FIAT purchase (not yet supported in v1) |

The `value` field is a string to support decimal precision for direct purchases.

## Entitlements

Each item must specify what the player receives on purchase:

```json
{
  "entitlementId": "speed_boost_effect",
  "quantity": 1,
  "consumable": true,
  "durationDays": 7
}
```

| Field           | Type    | Required | Description                             |
| --------------- | ------- | -------- | --------------------------------------- |
| `entitlementId` | string  | Yes      | ID of the entitlement to grant          |
| `quantity`      | number  | Yes      | How many to grant (must be > 0)         |
| `consumable`    | boolean | Yes      | Whether this is consumed on use         |
| `durationDays`  | number  | No       | For time-bound items, how long it lasts |

## Categories

| Category         | Description                                            |
| ---------------- | ------------------------------------------------------ |
| `consumable`     | Used up on use (e.g., potions, boosts)                 |
| `non_consumable` | Permanent ownership (e.g., skins, characters)          |
| `time_bound`     | Expires after a duration (e.g., subscriptions, passes) |

## Collections

Collections let you sell large catalogs (episodes, chapters, level packs) without listing every item in the config. A collection defines shared defaults (price, entitlement shape, refund policy) and the server trusts the client to know what items exist.

Any `(collectionId, itemId)` pair is valid. Items without an override entry use the collection's defaults. Only items that deviate from defaults need to be listed.

### Collection Fields

| Field               | Type    | Required | Description                                           |
| ------------------- | ------- | -------- | ----------------------------------------------------- |
| `collectionId`      | string  | Yes      | Unique identifier for the collection                  |
| `price`             | object  | Yes      | Default price `{ type, value }` for all items         |
| `entitlement`       | object  | Yes      | `{ consumable: boolean, durationDays?: number }`      |
| `refundEligible`    | boolean | Yes      | Default refund policy                                 |
| `refundWindowHours` | number  | Yes      | Default refund window                                 |
| `items`             | array   | No       | Item overrides: only items that deviate from defaults |

### Item Override Fields

| Field                       | Type      | Required | Description                      |
| --------------------------- | --------- | -------- | -------------------------------- |
| `itemId`                    | string    | Yes      | The item being overridden        |
| `tags`                      | string\[] | No       | Tags for sale targeting          |
| `priceOverride`             | object    | No       | Override price `{ type, value }` |
| `refundEligibleOverride`    | boolean   | No       | Override refund eligibility      |
| `refundWindowHoursOverride` | number    | No       | Override refund window           |

### Entitlement Mapping

Collection entitlements are derived automatically: the entitlement ID is `${collectionId}_${itemId}`, quantity is 1, and `consumable`/`durationDays` come from the collection config. No per-item entitlement configuration is needed.

Non-consumable collection items are treated as unique: a player cannot purchase the same item twice.

## SDK Usage

### Get Catalog

Fetch the full storefront. Returns all items with sale-resolved prices and a `configId` for version pinning.

```typescript
const storefront = await RundotGameAPI.shop.getCatalog()

console.log('Config version:', storefront.configId)
for (const item of storefront.items) {
  const { originalPrice, finalPrice, appliedSales } = item.resolvedPrice
  console.log(`${item.name}: ${originalPrice.value} → ${finalPrice.value}`)
  if (appliedSales.length > 0) {
    console.log('  On sale:', appliedSales.map(s => s.saleId).join(', '))
  }
}
```

By default the catalog excludes inactive, expired, and unreleased items. Pass the optional boolean flags to include them, for example when building an admin or preview view:

```typescript
// Include everything, even items not yet live
const fullCatalog = await RundotGameAPI.shop.getCatalog(true, true, true)
```

| Argument            | Type    | Default | Description                                       |
| ------------------- | ------- | ------- | ------------------------------------------------- |
| `includeInactive`   | boolean | `false` | Include items where `active` is false             |
| `includeExpired`    | boolean | `false` | Include items past their `expiresAt`              |
| `includeUnreleased` | boolean | `false` | Include items whose `releasedAt` is in the future |

### Get Item Detail

Fetch a single item by ID with its resolved price.

```typescript
const item = await RundotGameAPI.shop.getItemDetail('speed_boost')

console.log(item.name, item.description)
console.log('Price:', item.resolvedPrice.finalPrice.value)
console.log('Entitlements:', item.entitlements)
```

### StorefrontResponse

```typescript
interface StorefrontResponse {
  configId: string        // Server config version ID
  items: StorefrontItem[]
  collections?: StorefrontCollection[]
}
```

### Resolve Collection Item Price

The storefront includes collection info with pre-resolved pricing. Use `resolveCollectionItemPrice` to look up the price for any item in a collection locally, with no server round-trip needed.

This is a pure helper function, not a method on `RundotGameAPI.shop`. Import it directly from the package:

```typescript
import RundotGameAPI from '@series-inc/rundot-game-sdk/api'
import { resolveCollectionItemPrice } from '@series-inc/rundot-game-sdk'

const storefront = await RundotGameAPI.shop.getCatalog()

// Get the price for any episode, even ones not listed in the config
const price = resolveCollectionItemPrice(storefront, 'episodes', 'ep-42')
if (price) {
  console.log(`Price: ${price.finalPrice.value}`) // Uses collection defaults
}

// Override items get their own resolved price
const premiumPrice = resolveCollectionItemPrice(storefront, 'episodes', 'ep-premium-1')
if (premiumPrice) {
  console.log(`Premium price: ${premiumPrice.finalPrice.value}`) // Uses override price
}
```

It takes the storefront response plus a `(collectionId, itemId)` pair and returns the item's resolved price (the override's `resolvedPrice` if the item has one, otherwise the collection's `resolvedDefaults`). It returns `null` if the collection is not in the storefront.

| Argument       | Type                 | Description                      |
| -------------- | -------------------- | -------------------------------- |
| `storefront`   | `StorefrontResponse` | The response from `getCatalog()` |
| `collectionId` | string               | The collection to look in        |
| `itemId`       | string               | The item within the collection   |

Returns `StorefrontCollection['resolvedDefaults'] | null`.

### Purchase Collection Item

Buy an item from a collection. Like regular purchases, pass an `idempotencyKey` to prevent duplicates.

```typescript
const idempotencyKey = crypto.randomUUID()
const result = await RundotGameAPI.shop.purchaseCollectionItem('episodes', 'ep-42', idempotencyKey)

console.log('Order:', result.order.orderId)
console.log('Status:', result.order.status) // 'fulfilled'
console.log('Collection:', result.order.collectionId) // 'episodes'
```

### Purchase

Buy an item. Pass an `idempotencyKey` (for example `crypto.randomUUID()`) to prevent duplicate charges on retry.

```typescript
const idempotencyKey = crypto.randomUUID()
const result = await RundotGameAPI.shop.purchase('speed_boost', idempotencyKey)

console.log('Order:', result.order.orderId)
console.log('Status:', result.order.status) // 'fulfilled'
console.log('Paid:', result.order.finalPrice.value)
```

If the player has insufficient funds, the platform automatically opens the currency purchase flow. If the catalog has been updated since the player last fetched it, the SDK throws a stale catalog error: re-fetch the catalog and retry.

### Get Order

Retrieve a specific order by ID.

```typescript
const result = await RundotGameAPI.shop.getOrder(orderId)
const order = result.order

console.log('Status:', order.status)
for (const entry of order.statusHistory) {
  console.log(`  ${entry.status} @ ${entry.timestamp}`)
}
```

### Get Order History

Retrieve recent orders for the current game.

```typescript
const result = await RundotGameAPI.shop.getOrderHistory({ limit: 10 })
for (const order of result.orders) {
  console.log(`${order.orderId}: ${order.itemId} - ${order.status}`)
}
```

### Request Refund

Refund a recent purchase. The player gets their currency back and the entitlements are removed.

```typescript
const result = await RundotGameAPI.shop.requestRefund(orderId, 'changed_mind')

console.log('Status:', result.order.status) // 'refunded'
console.log('Refund amount:', result.order.refund.amount.value)
```

**Refund reason codes:** `changed_mind`, `accidental_purchase`, `not_as_expected`, `technical_issue`, `other`

**Refund constraints:**

* The item must have `refundEligible: true` in the config
* The request must be within `refundWindowHours` of the purchase
* Consumable entitlements must not have been used (quantity must equal what was originally granted)
* Non-refundable items (e.g., `refundEligible: false`) are always rejected

## Client-Side Analytics Events

The platform automatically tracks server-side analytics for purchases (`shop_purchase`), failed purchases (`shop_purchase_failed`), and refunds (`shop_refund`).

For client-side shop analytics, send the following events via `RundotGameAPI.analytics.logEvent()`:

| Event                       | When to Fire                                       | Properties                                       |
| --------------------------- | -------------------------------------------------- | ------------------------------------------------ |
| `shop_item_viewed`          | Player views an item detail page                   | `item_id`, `item_name`, `item_category`, `price` |
| `shop_item_click_purchase`  | Player taps the purchase button                    | `item_id`, `item_name`, `price`                  |
| `shop_item_cancel_purchase` | Player cancels/dismisses the purchase confirmation | `item_id`, `item_name`, `price`                  |
| `shop_item_view_duration`   | Player leaves an item detail page                  | `item_id`, `duration_ms`                         |

```typescript
// Example: track item viewed
RundotGameAPI.analytics.logEvent('shop_item_viewed', {
  item_id: item.itemId,
  item_name: item.name,
  item_category: item.category,
  price: item.resolvedPrice.finalPrice.value,
})
```

## Type Reference

### StorefrontItem

```typescript
interface StorefrontItem {
  itemId: string
  name: string
  description: string
  assets: { thumbnail?: string; banner?: string; icon?: string }
  price: { type: string; value: string }
  category: string
  unique: boolean
  active: boolean
  regions: string[]
  tags: string[]
  sortOrder: number
  releasedAt: number | null
  expiresAt: number | null
  entitlements: { entitlementId: string; quantity: number; consumable: boolean; durationDays?: number }[]
  refundEligible: boolean
  refundWindowHours: number
  resolvedPrice: {
    originalPrice: { type: string; value: string }
    finalPrice: { type: string; value: string }
    appliedSales: {
      saleId: string
      discountType: string
      discountValue: number
      discountPrice?: { type: string; value: string }
    }[]
  }
}
```

### StorefrontCollection

```typescript
interface StorefrontCollection {
  collectionId: string
  price: { type: string; value: string }
  entitlement: { consumable: boolean; durationDays?: number }
  refundEligible: boolean
  refundWindowHours: number
  resolvedDefaults: {
    originalPrice: { type: string; value: string }
    finalPrice: { type: string; value: string }
    appliedSales: { saleId: string; discountType: string; discountValue: number; discountPrice?: { type: string; value: string } }[]
  }
  items: {
    itemId: string
    resolvedPrice: {
      originalPrice: { type: string; value: string }
      finalPrice: { type: string; value: string }
      appliedSales: { saleId: string; discountType: string; discountValue: number; discountPrice?: { type: string; value: string } }[]
    }
  }[]
}
```

* **`resolvedDefaults`**: the collection's default price with any collection-wide sales applied. Use this for items without overrides.
* **`items`**: only items with overrides are listed, each with its own `resolvedPrice`. The element type is the named, importable `StorefrontCollectionItem` (`{ itemId, resolvedPrice }`).

### ShopOrder

```typescript
interface ShopOrder {
  orderId: string
  userId: string
  gameId: string
  configId: string
  collectionId?: string | null  // Set for collection purchases
  itemId: string
  itemSnapshot: {
    name: string
    price: { type: string; value: string }
    entitlements: { entitlementId: string; quantity: number; consumable: boolean; durationDays?: number }[]
  }
  originalPrice: { type: string; value: string }
  finalPrice: { type: string; value: string }
  appliedSales: {
    saleId: string
    discountType: string
    discountValue: number
    discountPrice?: { type: string; value: string }
  }[]
  status: string
  statusHistory: { status: string; timestamp: string; reason?: string }[]
  refund: null | {
    amount: { type: string; value: string }
    reasonCode: string
    requestedAt: string
    processedAt: string
  }
  idempotencyKey: string
  createdAt: string
  updatedAt: string
}
```

### ShopPurchaseResponse

The return type of `purchase()`, `purchaseCollectionItem()`, `getOrder()`, and `requestRefund()`. The examples above read `result.order` directly; the wrapper also carries a `success` boolean.

```typescript
interface ShopPurchaseResponse {
  success: boolean
  order: ShopOrder
}
```

### ShopOrderHistoryResponse

The return type of `getOrderHistory()`. The orders live under `orders`; `success` reports whether the lookup succeeded.

```typescript
interface ShopOrderHistoryResponse {
  success: boolean
  orders: ShopOrder[]
}
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://series-1.gitbook.io/rundot-docs/v5.21.0/readme/shop.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
