StoreKit 2 in-app purchase bindings (iOS / macOS) for Perry. For Android, use @perryts/play-billing.
| Target | Implementation |
|---|---|
| iOS 16+ | Native — Swift bridge over StoreKit 2 (Product.products(for:), etc.). |
| macOS 13+ | Native — same Swift bridge. |
| Android | Stub. Use @perryts/play-billing for Google Play Billing. |
| Linux / Windows | Stub — every call resolves with a "not available" JSON payload. |
No Swift toolchain, Xcode, or macOS host is required to build this package — for any target, including iOS:
- Linux / Windows / Android targets — pure Rust.
crate-stub(andcrate-ios, which compiles the same stub fallback for non-Apple targets) builds with nothing but a Rust toolchain. - iOS / macOS targets on a Mac —
crate-ios/build.rsdetectsswiftcviaxcrunand compilesswift/storekit_bridge.swiftfrom source, as before. - iOS / macOS targets on a non-Mac host — the npm package ships prebuilt
Swift bridge archives under
crate-ios/prebuilt/<rust-triple>/(built on macOS CI at publish time from the same Swift source). Whenbuild.rsfinds noswiftc, it links the prebuilt archive for the target triple instead. The Rust side is pure Rust (perry-ffi→dashmap,once_cell), socargo build --target aarch64-apple-iosneeds onlyrustup target add aarch64-apple-ios— no Apple SDK, no C compiler. Perry's Linux builders then link the resulting archive into the app withld64.lldand an Apple sysroot (PERRY_IOS_SYSROOT).
Set PERRY_STOREKIT_BRIDGE=source or =prebuilt to force either path.
When working from a git checkout (which does not contain the prebuilt
archives) on a non-Mac host, run scripts/build-bridges.sh once on a Mac
and copy crate-ios/prebuilt/ over — or consume the npm package, which
includes them.
npm install @perryts/storekitThe package targets perry-ffi ABI v0.5 (perry.nativeLibrary.abiVersion: "0.5" in package.json). Perry validates compatibility at build time.
import {
js_storekit_start_listener,
js_storekit_load_products,
js_storekit_purchase,
js_storekit_has_subscription,
js_storekit_get_jws,
js_storekit_restore,
} from "@perryts/storekit";
// Boot the Transaction.updates listener once at launch — handles
// Ask-to-Buy approvals, family-shared entitlements, auto-renew, etc.
js_storekit_start_listener();
// Load products you have configured in App Store Connect.
const productsJson = await js_storekit_load_products(
"com.example.pro_monthly,com.example.pro_annual",
);
const products = JSON.parse(productsJson);
// Drive the purchase sheet. The product must have been loaded first —
// StoreKit 2 needs the in-memory `Product` value to call `purchase()`.
const purchaseJson = await js_storekit_purchase("com.example.pro_monthly");
const purchase = JSON.parse(purchaseJson);
if (purchase.success) {
// purchase.jws → server-side receipt validation
// purchase.transactionId, purchase.purchaseDate → audit log
}
// Check entitlements at any time.
const subJson = await js_storekit_has_subscription();
const { hasSubscription } = JSON.parse(subJson);The native FFI returns JSON strings so the cross-language contract stays simple. In your app, wrap the calls with the types this package re-exports:
import {
js_storekit_load_products,
js_storekit_purchase,
js_storekit_has_subscription,
js_storekit_get_jws,
js_storekit_restore,
type Product,
type PurchaseResult,
type HasSubscriptionResult,
type JwsResult,
type RestoreResult,
} from "@perryts/storekit";
export async function loadProducts(ids: string[]): Promise<Product[]> {
const json = await js_storekit_load_products(ids.join(","));
const parsed = JSON.parse(json);
if (parsed && typeof parsed === "object" && "error" in parsed) {
throw new Error(parsed.error as string);
}
return parsed as Product[];
}
export async function purchase(productId: string): Promise<PurchaseResult> {
const json = await js_storekit_purchase(productId);
return JSON.parse(json) as PurchaseResult;
}
export async function hasSubscription(): Promise<boolean> {
const json = await js_storekit_has_subscription();
return (JSON.parse(json) as HasSubscriptionResult).hasSubscription;
}
export async function getJWS(): Promise<string | null> {
const json = await js_storekit_get_jws();
return (JSON.parse(json) as JwsResult).jws;
}
export async function restorePurchases(): Promise<RestoreResult> {
const json = await js_storekit_restore();
return JSON.parse(json) as RestoreResult;
}The Product and PurchaseResult shapes match the sketch in issue #537, with one practical addition: PurchaseResult.jws carries the App Store-issued JWS, which is what Apple's App Store Server API expects for server-side validation. (The legacy base64 receipt is no longer the recommended StoreKit 2 path.)
Start Transaction.updates in a detached Task. Verified transactions are automatically finish()-ed. Call exactly once at app launch — calling again cancels the previous listener.
Resolves with a JSON array of Product objects. On failure: {"error": "..."}.
Loaded products are cached in a Swift actor so js_storekit_purchase can look them up by ID.
Resolves with a JSON PurchaseResult. Possible shapes:
{ "success": true, "jws": "eyJ…", "productId": "…", "transactionId": "…", "purchaseDate": "2026-05-07T10:23:11.123Z", "cancelled": false }
{ "success": false, "cancelled": true }
{ "success": false, "pending": true }
{ "success": false, "error": "…" }Resolves with {"hasSubscription": boolean}. True iff at least one of Transaction.currentEntitlements is verified and has no revocationDate.
Resolves with {"jws": "…"} (most recent verified entitlement) or {"jws": null} (no active entitlement). Hand the JWS to your server, validate against Apple, then trust it.
Calls AppStore.sync(). Resolves with {"success": true} or {"error": "…", "success": false}. Apple recommends only invoking this from a user-tapped "Restore Purchases" button.
TypeScript Rust (perry-ffi 0.5) Swift (StoreKit 2)
------------------- ---------------------- ----------------------
js_storekit_purchase → #[no_mangle] extern "C" → @_cdecl bridge fn
fn js_storekit_purchase runs Task { … }
returns *mut Promise calls back with JSON
←─── promise.resolve_string(json) ←──────────
crate-ios/— Apple-platform crate. Depends onperry-ffi = "0.5"(tracked atgit+https://github.com/PerryTS/perry). When targeting iOS/macOS, itsbuild.rscompilesswift/storekit_bridge.swiftto a static lib and links it — or, on hosts withoutswiftc(Linux builders), links the prebuilt bridge archive shipped undercrate-ios/prebuilt/<rust-triple>/;package.jsonlistsStoreKitandFoundationso perry's link step adds-framework. For any other target,build.rsis a no-op and the crate compiles the same stub fallback ascrate-stub, so it builds on machines without Swift or Xcode.crate-stub/— non-Apple crate. Same exportedjs_storekit_*symbol set, but every call resolves immediately with a"not available on this platform"payload so calling code can fall back to a Stripe/web flow without#ifdef-style platform checks.package.json :: perry.nativeLibrary— declaresabiVersion: "0.5", the FFI symbol list, and per-targetcrate/lib/frameworks.
This binding does not validate JWS receipts itself — that's plain HTTPS against Apple's App Store Server API. A typical flow:
- Client:
js_storekit_purchase("…")→ JWS. - Client → your server:
POST /verify-storekit { jws }. - Server: validate signature, check
transactionId, mark entitlement. - Server → client: confirmation.
Periodically poll js_storekit_has_subscription() (or react to Transaction.updates) to keep the local cache fresh.
MIT