Llim.run

Test In-App Purchases

StoreKit can serve in-app purchase products from a local .storekit configuration file instead of from App Store Connect. Limrun's iOS instances expose this local test environment through three SDK calls: register a config, auto-generate one from your sandbox products, or clear it. Your app makes the same StoreKit calls it does in production; the simulator answers them from your file.

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 file (a .storekit configuration) instead of contacting App Store Connect. Xcode uses this same mechanism when you run a scheme with a StoreKit config selected; Limrun exposes it remotely through three SDK methods (setStoreKitConfig, discoverStoreKitConfig, clearStoreKitConfig).

Two practical consequences:

Provision an instance with the Xcode sandbox

Same create call you'd use for any build-and-test flow, with sandbox.xcode.enabled switched on:

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

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

// `wait: true` blocks until the instance is `ready` and the Xcode sandbox URL
// is populated on `status.sandbox.xcode.url`. `reuseIfExists` keeps you on the
// same warm instance on re-runs; pin a region if you want that to be reliable
// across networks (see Run an iOS Simulator).
const instance = await lim.iosInstances.create({
  wait: true,
  reuseIfExists: true,
  metadata: { labels: { name: 'iap-test' } },
  spec: {
    region: 'us-west',
    sandbox: { xcode: { enabled: true } },
  },
});
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"
)

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

instance, err := lim.IosInstances.New(context.TODO(), limrun.IosInstanceNewParams{
    Wait:          param.NewOpt(true),
    ReuseIfExists: param.NewOpt(true),
    Metadata: limrun.IosInstanceNewParamsMetadata{
        Labels: map[string]string{"name": "iap-test"},
    },
    Spec: limrun.IosInstanceNewParamsSpec{
        Region: param.NewOpt("us-west"),
        Sandbox: limrun.IosInstanceNewParamsSpecSandbox{
            Xcode: limrun.IosInstanceNewParamsSpecSandboxXcode{
                Enabled: param.NewOpt(true),
            },
        },
    },
})
import os
from limrun_api import Limrun

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

instance = limrun.ios_instances.create(
    wait=True,
    reuse_if_exists=True,
    metadata={"labels": {"name": "iap-test"}},
    spec={
        "region": "us-west",
        "sandbox": {"xcode": {"enabled": True}},
    },
)

When the call resolves, two URLs on the returned object matter for IAP work:

Both authenticate with the same instance.status.token.

Build and install the app

Sync your source to the Xcode sandbox and run xcodebuild. Critically, the build must be ad-hoc-signed so StoreKit's local test environment accepts the bundle. Simulator-target xcodebuild does ad-hoc signing by default when you don't pass certificate parameters, so you don't need to provision anything signing-related yourself. After a successful build, the app auto-installs on the attached simulator.

# Syncs the source folder and runs xcodebuild in one step. --id targets the
# iOS instance with the Xcode sandbox attached (created above).
lim xcode build ./MyApp --id <instance-id> --scheme MyApp --sdk iphonesimulator
const xcode = await lim.xcodeInstances.createClient({
  apiUrl: instance.status.sandbox!.xcode!.url!,
  token: instance.status.token,
});

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 on the same instance.
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 Xcode sync + build pipeline isn't exposed in the Python or Go SDKs today. From those languages, drive the build through lim xcode build as a subprocess. See Build with remote Xcode for the full surface (workspace, scheme, real-device signing, log streaming, artifact upload).

Pick a flow

The three StoreKit helpers live on the iOS device-control client:

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

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

setStoreKitConfig, clearStoreKitConfig, discoverStoreKitConfig, and softReset are TypeScript-only today. The Go iOS WebSocket client at github.com/limrun-inc/go-sdk/websocket/ios ships the rest of device control (taps, element-tree, screenshots, simctl) but not these StoreKit helpers, and the Python SDK is control-plane only. From Go or Python, drive the StoreKit + soft-reset steps from a Node subprocess (or call the underlying HTTP endpoints directly); the surrounding provision, build, launch, and tap steps still work in your primary language.

From here, you have two ways to populate the local config: register a file you already have, or auto-generate one from your existing App Store Connect setup.

The second option, discover, leans on what you've already done in App Store Connect. When you set up an iOS app for distribution, you also register every in-app purchase there: 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 parallel pre-production environment that real devices can fetch from when signed into a sandbox-tester Apple ID. Discover skips the tester sign-in: it has the simulator make one sandbox product request (when you open the paywall during a discoverStoreKitConfig call), captures the response from the StoreKit cache on the device, and turns it into a .storekit file. You get a config that exactly matches what App Store Connect has for that bundle, without authoring or maintaining a separate file.

FlowWhat you needWhen to use
Explicit (setStoreKitConfig)A .storekit file on diskYou already maintain a config in Xcode, or you want the test environment to be deterministic across runs.
Discover (discoverStoreKitConfig)IAPs registered in App Store Connect sandbox for your bundleYou have a working App Store Connect setup but no .storekit file. Limrun captures the sandbox response and converts it for you.

These two flows aren't mutually exclusive. Discover is convenient for the first run; explicit is what most teams settle on once they want their tests reproducible.

Explicit flow: upload a .storekit file

If you already have a .storekit (Xcode generates one via File → New → File → StoreKit Configuration File; you can also write one by hand), read the bytes and register them against 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). The simulator wires the config to bundleId immediately. The app's next StoreKit fetch will hit the local test environment instead of Apple's servers.

If your app is already running, restart it so it picks up the new config:

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

Discover flow: auto-generate from a sandbox response

If you don't have a .storekit on hand but your bundle ID already has IAPs registered in App Store Connect sandbox, Limrun can read the cached sandbox response from storekitd and turn it into a .storekit for you:

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 server-side until either a sandbox response gets cached or the timeout fires. While it's waiting, your app needs to trigger at least one StoreKit product fetch, which usually means opening the in-app paywall. Watch the simulator via instance.status.signedStreamUrl and drive the app there yourself, or automate it with the device-control tap and element-tree APIs.

If discover times out, it's almost always because either the bundle has no IAPs registered in sandbox yet, or the app didn't actually fetch products during the window. Re-running with a longer timeoutSeconds won't help in either case.

After discovery succeeds, the generated config is automatically registered for the bundle, exactly as if you'd called setStoreKitConfig. You don't need a second call.

Run a purchase

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

lim ios launch-app --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'll see on screen. The StoreKit purchase sheet looks identical to the production one, except the bottom of the sheet shows [Environment: Xcode] instead of [Environment: Production] or [Environment: Sandbox]. There's no Apple ID prompt, no sandbox-tester sign-in, and the confirmation step completes immediately rather than waiting on a network call to Apple.

What your app code sees. From your app's perspective the purchase is real. You await the purchase call, get back a signed transaction, and the listeners and entitlement checks you've already wired up run unchanged. The only thing that differs from a real-device purchase is the certificate that signs the transaction: it's Apple's local-test certificate, not their production one.

The one caveat: server-side receipt validation. If your backend verifies receipts by calling Apple's production or sandbox verifyReceipt / App Store Server API, those calls will reject the local-environment transaction because the signing certificate isn't Apple's production one. Stub the validation server out in your test environment, or short-circuit it when the build is ad-hoc-signed.

State persists across app relaunches. The simulator remembers the purchase as long as the local config is active. To clear it between test cases without dropping the config, soft-reset the app's data container:

// 'data' (default): terminate the app + wipe its data container. No reinstall.
await ios.softReset(bundleId);

// 'full': terminate + uninstall + clear keychain + clear privacy grants
// + drop NSUserDefaults + wipe shared App Group containers + reinstall
// from the cached source .app.
await ios.softReset(bundleId, { strategy: 'full' });

To roll the whole environment back to "no local config," see Clear or replace the config.

Clear or replace the config

Two ways to undo setStoreKitConfig:

// 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 a test teardown without checking state first.

Troubleshooting

Next steps

Run an iOS Simulator

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

Build with remote Xcode

The build options behind xcodebuild(): schemes, signing, log streaming, IPA output.

Automatic PR Previews

Ship every PR with a built app and a live preview link from GitHub Actions.