Llim.run

Test in-app purchases

StoreKit can serve in-app purchase products from a local .storekit configuration file instead of App Store Connect. Limrun iOS instances expose this local test environment through three TypeScript SDK calls: register a config, generate one from your sandbox products, or clear it. The CLI has no StoreKit command, so CLI users and coding agents run these steps from a short Node script. Your app makes the same StoreKit calls it makes in production, and the simulator answers them from the config.

Before you start

How the local test environment works

storekitd is the iOS daemon that brokers every StoreKit request between your app and Apple's servers. In the local test environment, it reads products from a JSON-shaped .storekit file instead of contacting App Store Connect. Xcode uses the same mechanism when you run a scheme with a StoreKit configuration selected; Limrun exposes it remotely through setStoreKitConfig, discoverStoreKitConfig, and clearStoreKitConfig.

This has two consequences:

Create an Xcode sandbox and an attached simulator

Create the two instances and attach the simulator to the sandbox, the same shape as any build-and-test flow:

lim xcode create --reuse-if-exists --label name=iap-test
lim ios create --attach --reuse-if-exists --label name=iap-test
import { Limrun, Ios } from '@limrun/api';

const lim = new Limrun({ apiKey: process.env.LIM_API_KEY });

// `wait: true` blocks until each instance is `ready`. `reuseIfExists` keeps
// you on the same warm instances on re-runs that land in the same region.
const xcodeInstance = await lim.xcodeInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { name: 'iap-test' } },
});

const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { name: 'iap-test' } },
});

const xcode = await lim.xcodeInstances.createClient({ instance: xcodeInstance });
await xcode.attachSimulator(instance);
import os
from limrun_api import Limrun

limrun = Limrun(api_key=os.environ["LIM_API_KEY"])

xcode_instance = limrun.xcode_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"name": "iap-test"}},
)

instance = limrun.ios_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"name": "iap-test"}},
)

# Attach the simulator with a direct HTTP call: POST {xcode status.api_url}/simulator
# with the Xcode sandbox's status.token as bearer and body
# {"apiUrl": instance.status.api_url, "token": instance.status.token}.
import (
    "context"
    "os"

    limrun "github.com/limrun-inc/go-sdk"
    "github.com/limrun-inc/go-sdk/option"
    "github.com/limrun-inc/go-sdk/packages/param"
)

ctx := context.Background()
lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))

// The Go SDK has no Xcode sandbox resource. Create the sandbox with
// `lim xcode create --reuse-if-exists --label name=iap-test`.
instance, err := lim.IosInstances.New(ctx, limrun.IosInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Metadata: limrun.IosInstanceNewParamsMetadata{
        Labels: map[string]string{"name": "iap-test"},
    },
})
if err != nil {
    panic(err)
}

// Attach the simulator with a direct HTTP call: POST {xcode status.apiUrl}/simulator
// with the Xcode sandbox's status.token as bearer and body
// {"apiUrl": instance.Status.APIURL, "token": instance.Status.Token}.

Two URLs matter for in-app purchase work:

Build and install the app

Sync your source to the Xcode sandbox and build it. The build must be ad-hoc signed so StoreKit's local test environment accepts the bundle. A simulator build without certificate parameters is ad-hoc signed by default, so there is nothing to set up. After a successful build, the app installs on the attached simulator.

# Syncs the source folder and runs xcodebuild in one step.
lim xcode build ./MyApp --id <xcode-instance-id> --scheme MyApp --sdk iphonesimulator
await xcode.sync('./MyApp', { watch: false });

// `xcodebuild()` with no signing params produces an ad-hoc-signed simulator
// build. After it succeeds, the build is auto-installed on the attached
// simulator.
const build = xcode.xcodebuild();
build.stdout.on('data', (line) => process.stdout.write(line.toString()));
build.stderr.on('data', (line) => process.stderr.write(line.toString()));
const { exitCode } = await build;
if (exitCode !== 0) throw new Error(`xcodebuild failed (${exitCode})`);

The Python and Go SDKs do not sync or build. From those languages, run lim xcode build as a subprocess. Build with Xcode covers workspaces, schemes, log streaming, and artifact upload.

Connect a device client

The three StoreKit helpers live on the iOS device client:

import { Ios } from '@limrun/api';

const ios = await Ios.createInstanceClient({
  apiUrl: instance.status.apiUrl!,
  token: instance.status.token!,
});

setStoreKitConfig, clearStoreKitConfig, and discoverStoreKitConfig are in the TypeScript SDK only. The released Go SDK (v0.9.0) iOS client, github.com/limrun-inc/go-sdk/websocket/ios, covers taps, the element tree, and screenshots but not these helpers, and the Python SDK only manages instances. From Go or Python, run the StoreKit steps from a Node subprocess or call the underlying HTTP endpoints; the create, build, launch, and tap steps still work in your primary language.

Pick a flow

You can populate the local config in two ways: register a file you already have, or generate one from App Store Connect.

The second option, discover, reuses work you have already done. When you set up an app for distribution, you register every in-app purchase in App Store Connect: a product ID, a price tier, localized titles and descriptions, and, for subscriptions, groups and durations. App Store Connect mirrors this data into a sandbox storefront, a pre-production environment that real devices reach when signed in with a sandbox tester Apple ID. Discover skips the tester sign-in. While discoverStoreKitConfig waits, the app makes one sandbox product request (when you open the paywall), Limrun captures the response from the StoreKit cache on the device, and turns it into a .storekit file that matches what App Store Connect has for that bundle.

FlowWhat you needWhen to use it
Explicit (setStoreKitConfig)A .storekit file on diskYou already maintain a config in Xcode, or you want the same products on every run.
Discover (discoverStoreKitConfig)In-app purchases registered in the App Store Connect sandbox for your bundleYou have a working App Store Connect setup but no .storekit file.

The flows combine well: discover is convenient for the first run, and most teams switch to an explicit file once they want reproducible tests.

Register a .storekit file

Read the file's bytes and register them for the bundle ID:

import fs from 'node:fs';

const bundleId = 'com.example.MyApp';
const bytes = fs.readFileSync('./Products.storekit');

await ios.setStoreKitConfig(bundleId, bytes);

setStoreKitConfig takes the raw file bytes (Buffer or Uint8Array) and applies the config to the bundle immediately. The app's next StoreKit fetch uses the local test environment instead of Apple's servers. If the app is already running, restart it so it picks up the config:

await ios.terminateApp(bundleId);
await ios.launchApp(bundleId);

Generate a config from sandbox products

If your bundle ID already has in-app purchases registered in the App Store Connect sandbox, Limrun can read the cached sandbox response from storekitd and turn it into a config:

const result = await ios.discoverStoreKitConfig(bundleId, {
  timeoutSeconds: 120,  // default 120; server caps at 300
});

console.log(`Found ${result.itemsFound} items: ` +
  `${result.productsCount} products, ` +
  `${result.subscriptionsCount} subscriptions in ` +
  `${result.subscriptionGroupsCount} groups.`);

The call blocks on the server until a sandbox response is cached or the timeout fires. While it waits, the app must fetch StoreKit products at least once, which usually means opening the paywall. Drive the app yourself through instance.status.signedStreamUrl, or automate it with the device client.

After discovery succeeds, the generated config is registered for the bundle, exactly as if you had called setStoreKitConfig. No second call is needed.

Run a purchase

Both flows leave the simulator in the same state: products and prices come from the local config. Drive the paywall and complete a purchase like any other UI test:

lim ios launch-app --detach --id <instance-id> com.example.MyApp
lim ios open-url   --id <instance-id> "myapp://settings/upgrade"
lim ios tap-element --id <instance-id> --ax-label "Subscribe Monthly"
# StoreKit shows its purchase sheet. Tap the confirm button to complete.
lim ios tap-element --id <instance-id> --ax-label "Confirm"
await ios.launchApp(bundleId);

// However your app reaches the paywall: a deep link, a tab, a button tree.
await ios.openUrl('myapp://settings/upgrade');
await ios.tapElement({ AXLabel: 'Subscribe Monthly' });

// StoreKit shows its purchase sheet. Tap the confirm button to complete.
await ios.tapElement({ AXLabel: 'Confirm' });

What you see on screen. The StoreKit purchase sheet looks like the production one, except the bottom shows [Environment: Xcode] instead of [Environment: Production] or [Environment: Sandbox]. There is no Apple ID prompt and no sandbox tester sign-in, and the confirmation completes without a network call to Apple.

What your app sees. From the app's side the purchase is real. You await the purchase call, get back a signed transaction, and your listeners and entitlement checks run unchanged. The only difference from a device purchase is the signing certificate: it is Apple's local test certificate, not the production one.

Server-side receipt validation fails. If your backend verifies receipts through Apple's production or sandbox verifyReceipt or the App Store Server API, those calls reject local-environment transactions because the signing certificate is not Apple's production one. Stub the validation server in your test environment, or skip it when the build is ad-hoc signed.

Purchases persist across relaunches. The simulator remembers a purchase while the local config is active. softReset wipes the app's own data container, but the StoreKit test state lives outside it, so it is not a documented way to clear purchases. For a clean purchase state between test cases, create a fresh simulator, for example with --rm or a new label per test run.

Clear or replace the config

Clear the config for a bundle, or overwrite it with another file:

// Wipe the local config for this bundle. Future StoreKit requests fall back
// to the real sandbox (or fail if no sandbox account is signed in).
await ios.clearStoreKitConfig(bundleId);

// Or overwrite with a different .storekit. The latest call wins.
await ios.setStoreKitConfig(bundleId, fs.readFileSync('./OtherProducts.storekit'));

clearStoreKitConfig is safe to call when nothing is registered, so you can put it in test teardown without checking state first.

Troubleshooting

Next steps

smartphone

Run a simulator

The full device-control surface: taps, element queries, screenshots, app lifecycle.

hammer

Build with Xcode

Schemes, log streaming, and artifact upload behind xcodebuild().

git-pull-request

PR previews

Build every pull request and post a live preview link from GitHub Actions.