expo-iap-utils
v1.1.0
Published
Expo module with lightweight read-only StoreKit 2 / Google Play Billing utilities — storefront country, transaction-based identity recovery, and other helpers that bypass the IAP/subscription engine.
Maintainers
Readme
expo-iap-utils
A small Expo module with read-only StoreKit 2 / Google Play Billing utilities that intentionally bypass the heavyweight IAP/subscription engines. Each util encapsulates its own short-lived native lifecycle, so JS callers never have to think about initConnection, endConnection, or transaction listeners.
Designed to coexist with subscription SDKs (Adapty, RevenueCat, Qonversion, react-native-iap) without fighting them for the global StoreKit/BillingClient state.
Available utilities
| Function | Returns |
| --- | --- |
| getStorefrontCountry() | Active App Store storefront / Play billing country as lowercase ISO 3166-1 alpha-2 ("us", "de", …) |
| getCustomerUserIdFromTransactions() | The app-embedded identifier (appAccountToken on iOS, obfuscatedAccountId on Android) carried by past purchases on the active store account, or null if none |
| syncStoreTransactions() | Forces StoreKit to refresh transactions from Apple (StoreKit 2 AppStore.sync()); no-op on Android. Use from user-initiated "Restore Purchases" only |
More utilities will be added here as the library grows.
Why
If you already have a subscription SDK, you usually don't want a second IAP framework in the binary just to ask one or two read-only questions about the store account. This library covers those edge cases:
- Real storefront country for pricing/LTV reporting, distinct from device locale.
- Identity recovery from past transactions for apps with no traditional auth — read the user identifier the app itself embedded at purchase time, surviving reinstalls and device changes on the same store account.
Installation
npx expo install expo-iap-utilsThen rebuild your native project:
npx expo prebuild --clean # if you use CNG
npx pod-install # iOSRequirements
| Platform | Minimum |
| --- | --- |
| iOS | 15.1 (StoreKit 2) |
| Android | API 21 + Google Play Services |
| Web | Modern browser with Intl.Locale |
API
getStorefrontCountry(): Promise<string | null>
Lowercase ISO 3166-1 alpha-2 of the active storefront, or null if it cannot be determined.
import { getStorefrontCountry } from 'expo-iap-utils';
const country = await getStorefrontCountry();
// → "us" | "de" | "jp" | … | nullThe first successful call is cached for the JS runtime lifetime (storefront cannot change without an app restart). Failed lookups are not cached, so transient cold-start misses retry on the next call.
How it works
iOS — StoreKit 2 await Storefront.current?.countryCode. Async getter that suspends until StoreKit resolves the active App Store account; no SKPaymentTransactionObserver or other setup required. Returns alpha-3 (e.g. "USA"); JS normalizes to alpha-2 via a 249-entry mapping.
Android — short-lived BillingClient → getBillingConfigAsync → endConnection. Independent from any other BillingClient your app may have open (e.g. an IAP SDK's). Returns null for every failure mode (no Play Services, setup failure, billing error) so JS can fall back.
Web — Intl.Locale(navigator.language).region, normalized through the same pipeline.
Contract
- Output is guaranteed to be either:
- A two-character, lowercase, ASCII string matching
^[a-z]{2}$, or null.
- A two-character, lowercase, ASCII string matching
- No
undefined, no uppercase, no whitespace. - Unknown ISO codes (e.g. if Apple adds a new region not in the lookup table) resolve to
nullrather than leaking a raw alpha-3.
getCustomerUserIdFromTransactions(): Promise<string | null>
Reads the per-purchase identifier the app embedded at purchase time — appAccountToken on iOS (StoreKit 2) or obfuscatedAccountId on Android (Play Billing) — by walking on-device transaction history. Returns the first usable token, or null if none is stored.
import { getCustomerUserIdFromTransactions } from 'expo-iap-utils';
const recoveredId = await getCustomerUserIdFromTransactions();
// → "550E8400-E29B-41D4-A716-446655440000" | nullIntended for apps without traditional authentication that use the purchase token as a stable per-user identifier across reinstalls and devices on the same store account.
When it returns null
- The user has never purchased anything on this store account.
- Their purchase predates the app passing
appAccountToken/obfuscatedAccountId. - The platform has no readable transaction history (web, no Play Services, etc.).
How it works
iOS — iterates StoreKit.Transaction.currentEntitlements first (active subs / non-consumables), then falls back to Transaction.all (full history, for users whose subscription has lapsed). Skips .familyShared ownership: a Family Sharing inheritor's token belongs to the family organizer, not the current user. Skips unverified results. Returns the UUID uppercased, or null.
Android — short-lived BillingClient → queryPurchasesAsync(SUBS) → queryPurchasesAsync(INAPP) → endConnection. Returns the first non-empty obfuscatedAccountId from Purchase.getAccountIdentifiers(). Independent from any other BillingClient (e.g. an IAP SDK's).
Web — always null.
Caveats
- The function reads what the app itself stored at purchase time. You must pass the identifier when initiating purchases (StoreKit 2:
Product.PurchaseOption.appAccountToken(uuid); Play Billing:BillingFlowParams.Builder().setObfuscatedAccountId(...)). Most subscription SDKs (Adapty, RevenueCat, etc.) accept this value at activation. - iOS
appAccountTokenis aUUID. If your in-app user IDs are not UUIDs, generate a deterministic UUIDv5 from your user ID before passing it to the store. - Not memoized — caller decides if/when to re-query.
syncStoreTransactions(): Promise<boolean>
Forces StoreKit to fetch the latest transactions and subscription status from Apple's servers, wrapping StoreKit 2's AppStore.sync(). On Android this is a no-op (Google Play Billing queryPurchasesAsync is always fresh) and resolves true so callers don't need to branch on Platform.OS.
import { syncStoreTransactions, getCustomerUserIdFromTransactions } from 'expo-iap-utils';
// Inside a "Restore Purchases" button handler
async function onRestorePressed() {
await syncStoreTransactions();
const recovered = await getCustomerUserIdFromTransactions();
// … hand off to your auth/identity flow
}⚠️ Apple guideline: the iOS sync may pop a system "Sign In to App Store" sheet if the device isn't signed in. Apple's review team penalizes apps that show that sheet outside an explicit user-initiated action. Call this only from a "Restore Purchases" button handler (or equivalent), never from app start, background refresh, or implicit recovery.
Why pair it with getCustomerUserIdFromTransactions()
Transaction.all and Transaction.currentEntitlements read from StoreKit's local cache. On a freshly reinstalled app (or after switching Apple ID) that cache may be empty until StoreKit syncs. syncStoreTransactions() is the documented way to force that sync before reading.
Return value
true— sync succeeded (iOS) or no-op (Android).false— sync threw on iOS (most often the user dismissed the sign-in sheet, or there was no network), or the platform has no native module (web).
Comparison to other approaches
| Source | What it returns | Notes |
| --- | --- | --- |
| expo-localization regionCode | Device region (alpha-2) | Diverges from the user's store account |
| react-native-iap getStorefront() / getAvailablePurchases() | Storefront / past purchases | Requires initConnection lifecycle and registers global IAP listeners — risk of double-handling alongside another subscription SDK |
| AdaptyPaywallProduct.regionCode | Storefront (alpha-2) | Requires loading a paywall first |
| expo-iap-utils | Storefront, transaction-based identity | No setup, no listeners, coexists with subscription SDKs |
License
MIT © Dmitry Matatov
