purchasePackage(packageIdentifier) starts the native store purchase and
returns a discriminated union. Handle every status, and never unlock just
because the call returned.
The result union
type PurchaseResult =
| { status: 'cancelled' }
| { status: 'pending'; purchaseId: string }
| { status: 'verified'; customerInfo: CustomerInfo }
| { status: 'failed'; error: SubKitSerializableError }SubKitSerializableError carries code, message, retryable, and optional
metadata.
A complete handler
import { client } from '@piparotech/subkit-expo'
async function buySelectedPackage(packageIdentifier: string) {
try {
const result = await client.purchasePackage(packageIdentifier)
switch (result.status) {
case 'verified': {
const access = await client.getAccess('pro')
if (access.state === 'granted') {
unlockPaidAccess()
return
}
// Verified commerce evidence can still resolve to inactive, missing,
// or device recovery. Keep access locked and render that decision.
showVerifiedWithoutEntitlement()
return
}
case 'pending': {
// The store accepted or started the purchase, but SubKit has not
// confirmed the entitlement yet. Normal for Expo IAP. Do not unlock.
showPurchasePendingMessage()
const access = await client.getAccess('pro')
if (access.state === 'granted') unlockPaidAccess()
return
}
case 'cancelled': {
// User closed the store sheet. Keep the paywall open; no error toast.
return
}
case 'failed': {
if (result.error.metadata?.purchaseMayHaveCompleted === true) {
showPurchaseRecovery(result.error)
return
}
if (result.error.retryable) {
showRetryablePurchaseError(result.error.message)
} else {
showPurchaseUnavailableMessage(result.error.message)
}
return
}
}
} catch (error) {
// Network, store, runtime, or unexpected native error.
reportPurchaseError(error)
showPurchaseFailedMessage()
}
}What each status means
-
verified— SubKit verified the purchase server-side and returned freshCustomerInfo. Still check the entitlement before unlocking: a verified purchase for a different product does not grant your entitlement. -
pending— the common outcome with the Expo IAP adapter. Entitlement confirmation happens through SubKit sync (automatic, foreground, or a latersyncPurchases({ force: true, reason: 'queue_retry' })). Show a confirming state and block repeat purchase.getAccess()reads access but does not resume queued receipts. -
cancelled— user intent, not an error. Store-sheet cancellations are detected from the native error and normalized to this status. -
failed— an expected domain failure. Known codes include:Code Meaning Retryable missing_identityNo appUserId— identify before purchasingno product_unavailablePackage unknown, no store product for this platform, or no eligible Google offer no store_unavailableNative store error without a specific code yes
A terminal verification rejection can happen after a Store charge. Such failures
carry error.metadata.purchaseMayHaveCompleted: true; keep the purchase
blocked and offer reconciliation or support instead of another purchase.
Ownership conflicts include error.metadata.resolution. After a native Store
response, transport exceptions during reconciliation return pending, never
cancelled or a safe-to-retry purchase failure.
Throws still happen
Some native, network, or runtime failures throw instead of returning
{ status: 'failed' }. Always wrap purchases in try/catch.
What the SDK resolves for you
From the selected package, the SDK resolves the native product ID for the
current platform and the applicable Google base-plan/offer token. If a
configured Google offer is not currently returned as eligible by Play Billing,
the purchase fails closed as product_unavailable instead of silently
buying a different offer. Store identity hints (appAccountToken,
obfuscatedAccountId) are attached automatically.
Consumables
Apple and Google redeliver unfinished subscriptions and non-consumables on later syncs. iOS consumables do not come back from the store once lost — the SDK’s durable queue preserves consumable purchase events across app restarts until SubKit reconciles them. This is a key reason not to replace the default queue with a memory queue in production. See Advanced configuration.