> 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/purchases.md).

# Purchases API

Let players spend RunBucks to buy digital goods and entitlements in your game. The SDK handles platform billing flows: your game just requests balances and initiates transactions.

## What Are RunBucks?

RunBucks are the platform's hard currency. Players acquire RunBucks through the platform store, then spend them inside games for digital goods, power-ups, cosmetics, or other entitlements.

{% hint style="warning" %}
All SDK methods can reject; unhandled rejections crash the app. Always wrap SDK calls in `try/catch` or attach a `.catch()` handler. See [Error Handling](/rundot-docs/v5.21.0/readme/error-handling.md) for details.
{% endhint %}

> **Note**: "In-App Purchases" refers to FIAT transactions on the platform or in-game. This API specifically handles **RunBucks purchases**: transactions where users spend RunBucks inside your game.

## Quick Start

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

// Check player's RunBucks balance
const balance = await RundotGameAPI.iap.getHardCurrencyBalance()
console.log(`Player has ${balance} RunBucks`)

// Spend RunBucks on an item
const purchase = await RundotGameAPI.iap.spendCurrency('bundle_sword', 1)
if (purchase.success) {
  unlockItem('bundle_sword')
}
```

## Storefront Helpers

```typescript
// Launch the native RunBucks store. The result tells you whether a purchase
// happened and gives you the refreshed balance without a second call.
const store = await RundotGameAPI.iap.openStore()
if (store.purchased) {
  updateBalanceUI(store.newBalance)
}

// Fetch currency icon for your UI. getCurrencyIcon resolves to { base64Data },
// so build a data URI before assigning it to an <img> src.
const icon = await RundotGameAPI.iap.getCurrencyIcon()
document.querySelector('#currency-icon').src =
  'data:image/png;base64,' + icon.base64Data
```

## Complete Purchase Flow

```typescript
async function purchaseItem(itemId: string, cost: number) {
  // 1. Attempt purchase
  try {
    // spendCurrency includes an automatic hard currency purchase flow if the user does not have enough.
    // Pass a `description` so the host shows the player what they are buying in the confirmation dialog.
    const result = await RundotGameAPI.iap.spendCurrency(itemId, cost, {
      description: 'Unlock the legendary sword',
    })

    // 2. Track the purchase. spendCurrency does not return a balance, so fetch
    //    the refreshed balance afterward if you need it.
    if (result.success) {
      const newBalance = await RundotGameAPI.iap.getHardCurrencyBalance()
      await RundotGameAPI.analytics.recordCustomEvent('purchase_complete', {
        itemId,
        cost,
        newBalance,
      })

      return { success: true, newBalance }
    }

    // The player declined the confirmation dialog. No currency was deducted.
    if (result.error === 'USER_CANCELLED') {
      return { success: false, reason: 'cancelled' }
    }

    return { success: false, reason: 'purchase_failed', error: result.error }
  } catch (error) {
    return { success: false, reason: 'error', error }
  }
}
```

You can use `hasUserMadePurchase` to check if the user has ever made a purchase on RUN. You could use this information to adjust prices in your store, or show certain bundles only to non-spenders

## Subscription Paywall with Checkout Flow

```typescript
import { useEffect, useState } from 'react';
import RundotGameAPI from '@series-inc/rundot-game-sdk/api';
import {
  RunSubscriptionsResponse,
  SubscriptionTier,
  SubscriptionInterval,
} from '@series-inc/rundot-game-sdk';

function Paywall() {
  const [subscriptions, setSubscriptions] = useState<RunSubscriptionsResponse>({});

  // Fetch all subscriptions across all tiers, or pass a tier to filter
  useEffect(() => {
    RundotGameAPI.iap
      .getSubscriptions() // optionally pass a tier: getSubscriptions('CORE') if you want just CORE subscriptions
      .then(setSubscriptions);
  }, []);

  // Trigger the checkout flow for a given tier + interval
  const handlePurchase = async (tier: SubscriptionTier, interval: SubscriptionInterval) => {
    const result = await RundotGameAPI.iap.purchaseSubscription(tier, interval);
    if (result.success) {
      alert('Thanks for subscribing!');
    } else {
      // handle error
    }
  };

  return (
    <div>
      {Object.entries(subscriptions).map(([tier, packages]) => (
        <div key={tier}>
          <h2>{tier}</h2>
          {packages.map((sub) => (
            <button
              key={sub.interval}
              onClick={() => handlePurchase(tier as SubscriptionTier, sub.interval)}
            >
              <strong>{sub.description}</strong>
              <span>
                {sub.currencyCode} {sub.price} / {sub.interval}
              </span>
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

export default Paywall;
```

## Checking Subscription Status

Use isUserSubscribed to gate content or features behind a subscription tier. This method respects tier hierarchy: if a user has a higher tier than the one being checked, it returns true. The tier hierarchy is (lowest to highest) Core, Plus, Prime, Ultimate

```typescript
// Returns true if the user has CORE, PLUS, PRIME, or ULTIMATE
const hasCoreAccess = await RundotGameAPI.iap.isUserSubscribed('CORE');

// Returns true only if the user has ULTIMATE
const hasUltimateAccess = await RundotGameAPI.iap.isUserSubscribed('ULTIMATE');

// Example: gate a feature behind PLUS or higher
if (await RundotGameAPI.iap.isUserSubscribed('PLUS')) {
  unlockPlusFeatures();
} else {
  showUpgradePrompt();
}
```

## API Reference

<table><thead><tr><th width="276.2265625">Method</th><th>Returns</th><th>Description</th></tr></thead><tbody><tr><td><code>getHardCurrencyBalance()</code></td><td><code>Promise&#x3C;number></code></td><td>Get player's current RunBucks balance</td></tr><tr><td><code>spendCurrency(productId, amount, options?)</code></td><td><code>Promise&#x3C;{ success: boolean; error?: string }></code></td><td>Spend RunBucks on an item. Accepts an optional <code>options</code> object (see below). On <code>success: false</code>, <code>error</code> carries the reason (<code>'USER_CANCELLED'</code> when the player declines).</td></tr><tr><td><code>openStore()</code></td><td><code>Promise&#x3C;{ purchased: boolean; newBalance: number }></code></td><td>Open the native RunBucks store. Read <code>purchased</code> and the refreshed <code>newBalance</code> after it closes instead of refetching the balance.</td></tr><tr><td><code>getCurrencyIcon()</code></td><td><code>Promise&#x3C;{ base64Data: string }></code></td><td>Get the RunBucks icon as raw base64. Wrap it in a data URI (<code>'data:image/png;base64,' + icon.base64Data</code>) to use as an <code>&#x3C;img></code> src.</td></tr><tr><td><p><code>isUserSubscribed(</code></p><p><code>tier)</code></p></td><td><code>Promise&#x3C;boolean></code></td><td>Check if the user has a certain subscription tier. Will also return true if the user has a higher subscription tier.</td></tr><tr><td><p><code>getSubscriptions(</code></p><p><code>tier?)</code></p></td><td><code>Promise&#x3C;RunSubscriptionsResponse></code></td><td>Get a list of subscriptions available by tier. Use these to show a paywall to the user. You can optionally pass a tier to this method to get subscriptions for that tier only.</td></tr><tr><td><code>purchaseSubscription(tier, interval)</code></td><td><code>Promise&#x3C;PurchaseSubscriptionResponse></code></td><td>Trigger a checkout flow for a given subscription (identified by its tier and interval</td></tr><tr><td><code>hasUserMadePurchase()</code></td><td><code>Promise&#x3C;boolean></code></td><td>Check if the user has ever made a purchase on RUN</td></tr></tbody></table>

### `spendCurrency(productId, amount, options?)`

`spendCurrency` takes an optional third argument, `SpendCurrencyOptions`. Every field is optional. Both `SpendCurrencyOptions` and the `SpendCurrencyResult` it resolves to are named, importable types from the package root, `@series-inc/rundot-game-sdk` (not the `/api` subpath), so you can annotate your own helpers with them.

| Field           | Type     | Required | Description                                                                                                                                                                                                                         |
| --------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `screenName`    | `string` | No       | Screen/route name where the purchase occurred. Used for analytics only.                                                                                                                                                             |
| `description`   | `string` | No       | Short human-readable label of what is being bought (e.g. "Unlock level 5"). The host renders this in the spend-confirmation dialog so the player sees what they are paying for. Sanitized and truncated by the host before display. |
| `beneficiaryId` | `string` | No       | Profile id of a UGC creator the spend should be attributed to for revenue share. Forwarded to the server for revshare bookkeeping.                                                                                                  |

It resolves to a `SpendCurrencyResult`:

| Field     | Type                | Description                                                                                                                                                                                                                      |
| --------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `success` | `boolean`           | Whether the spend completed.                                                                                                                                                                                                     |
| `error`   | `string` (optional) | Present only on `success: false`. The one stable value is `'USER_CANCELLED'` (the player declined the host dialog; no currency was deducted). Any other value is a server-side message and is not stable, so don't branch on it. |

`spendCurrency` does not return a balance. If you need the post-spend balance, call `getHardCurrencyBalance()` afterward.

```typescript
const result = await RundotGameAPI.iap.spendCurrency('continue_run', 5, {
  description: 'Continue your run',
  screenName: 'game_over',
})

if (result.success) {
  resumeGame()
} else if (result.error === 'USER_CANCELLED') {
  // Player backed out of the confirmation dialog; nothing was charged.
} else {
  showError(result.error)
}
```

### `openStore(): Promise<OpenStoreResult>`

Opens the native RunBucks store and resolves once it closes. The result lets you react to a purchase without a separate balance fetch. `OpenStoreResult` is a named, importable type from the package root, `@series-inc/rundot-game-sdk` (not the `/api` subpath).

| Field        | Type      | Description                                                            |
| ------------ | --------- | ---------------------------------------------------------------------- |
| `purchased`  | `boolean` | Whether a purchase was completed during the store session.             |
| `newBalance` | `number`  | The updated RunBucks balance after the store closed (saves a refetch). |

### `getCurrencyIcon(): Promise<{ base64Data: string }>`

Returns the RunBucks icon as raw base64, not a URL. Build a data URI before assigning it to an `<img>` src.

```typescript
const icon = await RundotGameAPI.iap.getCurrencyIcon()
document.querySelector('#currency-icon').src =
  'data:image/png;base64,' + icon.base64Data
```

## Subscription Types

These types are exported from the package root, `@series-inc/rundot-game-sdk` (not the `/api` subpath).

### `SubscriptionTier`

The canonical tier union accepted by `isUserSubscribed`, `getSubscriptions`, and `purchaseSubscription`. Lowest to highest: `'CORE'`, `'PLUS'`, `'PRIME'`, `'ULTIMATE'`.

```typescript
type SubscriptionTier = 'CORE' | 'PLUS' | 'PRIME' | 'ULTIMATE';
```

### `SubscriptionInterval`

The billing interval passed to `purchaseSubscription` and returned on each `RunSubscription`. The accepted values are lowercase string literals.

```typescript
type SubscriptionInterval = 'weekly' | 'monthly' | 'annual';
```

{% hint style="warning" %}
The uppercase labels in the Offerings tables below (WEEKLY/MONTHLY/ANNUAL) are display-only. Pass the lowercase literals (`'weekly'`, `'monthly'`, `'annual'`) to the API.
{% endhint %}

### `RunSubscription`

One purchasable subscription package (a single tier + interval combination), as returned by `getSubscriptions`.

| Field          | Type                   | Description                                                |
| -------------- | ---------------------- | ---------------------------------------------------------- |
| `currencyCode` | `string`               | Currency code for the subscription price.                  |
| `interval`     | `SubscriptionInterval` | The billing interval. `'weekly'` charges the buyer weekly. |
| `price`        | `number`               | The price, in the units of `currencyCode`.                 |
| `description`  | `string`               | The product description.                                   |

### `RunSubscriptionsResponse`

The shape `getSubscriptions` resolves to: a map keyed by tier name, where each value is an array of `RunSubscription` packages (one per interval).

```typescript
interface RunSubscriptionsResponse {
  [tier: string]: RunSubscription[];
}
```

### `PurchaseSubscriptionResponse`

The shape `purchaseSubscription` resolves to. `success` is `true` on a completed checkout and `false` on cancel or error.

```typescript
interface PurchaseSubscriptionResponse {
  success: boolean;
}
```

***

## Subscription Offerings

### Tier: CORE

| Interval | Price  |
| -------- | ------ |
| WEEKLY   | $1.99  |
| MONTHLY  | $7.99  |
| ANNUAL   | $79.99 |

```ts
// Returns true if the user has CORE or higher
const hasCoreAccess = await RundotGameAPI.iap.isUserSubscribed('CORE');
```

```ts
RundotGameAPI.iap.getSubscriptions() // optionally pass a tier: getSubscriptions('CORE') if you want just CORE subscriptions

const result = await RundotGameAPI.iap.purchaseSubscription('CORE', 'weekly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('CORE', 'monthly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('CORE', 'annual');
```

***

### Tier: PLUS

| Interval | Price   |
| -------- | ------- |
| WEEKLY   | $2.99   |
| MONTHLY  | $11.99  |
| ANNUAL   | $119.99 |

```ts
// Returns true if the user has PLUS or higher
const hasPlusAccess = await RundotGameAPI.iap.isUserSubscribed('PLUS');
```

```ts
RundotGameAPI.iap.getSubscriptions() // optionally pass a tier: getSubscriptions('PLUS') if you want just PLUS subscriptions

const result = await RundotGameAPI.iap.purchaseSubscription('PLUS', 'weekly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('PLUS', 'monthly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('PLUS', 'annual');
```

***

### Tier: PRIME

| Interval | Price   |
| -------- | ------- |
| WEEKLY   | $9.99   |
| MONTHLY  | $34.99  |
| ANNUAL   | $299.99 |

```ts
// Returns true if the user has PRIME or higher
const hasPrimeAccess = await RundotGameAPI.iap.isUserSubscribed('PRIME');
```

```ts
RundotGameAPI.iap.getSubscriptions() // optionally pass a tier: getSubscriptions('PRIME') if you want just PRIME subscriptions

const result = await RundotGameAPI.iap.purchaseSubscription('PRIME', 'weekly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('PRIME', 'monthly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('PRIME', 'annual');
```

***

### Tier: ULTIMATE

| Interval | Price   |
| -------- | ------- |
| WEEKLY   | $14.99  |
| MONTHLY  | $39.99  |
| ANNUAL   | $349.99 |

```ts
// Returns true if the user has ULTIMATE or higher
const hasUltimateAccess = await RundotGameAPI.iap.isUserSubscribed('ULTIMATE');
```

```ts
RundotGameAPI.iap.getSubscriptions() // optionally pass a tier: getSubscriptions('ULTIMATE') if you want just ULTIMATE subscriptions

const result = await RundotGameAPI.iap.purchaseSubscription('ULTIMATE', 'weekly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('ULTIMATE', 'monthly');
// or
const result = await RundotGameAPI.iap.purchaseSubscription('ULTIMATE', 'annual');
```

## Best Practices

* Check balances before attempting to spend to avoid bouncing players with generic errors.
* Handle rejections gracefully: users may cancel or payments can fail mid-flow.
* The spend and purchase results expose no receipt or transaction ID. If you need an audit trail, capture the `productId` and cost yourself (for example via `RundotGameAPI.analytics`); the result only returns `success`/`error`.
* Combine with `RundotGameAPI.analytics` to attribute purchases by SKU.
* Always show the player's current balance in your shop UI.


---

# 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/purchases.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.
