The SDK separates three kinds of “something didn’t happen”:
- Domain outcomes — expected results like
{ status: 'failed' }orentitlements[key]being inactive. Not exceptions. - Thrown errors — network, store, runtime, or unexpected native failures.
- Access lifecycle states — refresh failures become
offline_unavailableorerrorwhen no bounded cached decision exists. Valid cached decisions remain regular states with offline evidence.
Domain outcomes are not exceptions
allowed-style checks and purchase failures return values:
const result = await client.purchasePackage(pkg.identifier)
if (result.status === 'failed') {
// expected domain failure — inspect result.error
result.error.code // e.g. 'missing_identity', 'product_unavailable'
result.error.message
result.error.retryable // drive your retry UI from this
result.error.metadata // optional extra context
}import { client } from '@piparotech/subkit-expo'Design rule: cancelled and failed are UI states, not crash reports. Only
unexpected throws belong in your error reporter.
Thrown errors
Anything the SDK cannot express as a domain outcome throws. Always wrap purchase and restore flows:
try {
const result = await client.purchasePackage(pkg.identifier)
handleResult(result)
} catch (error) {
reportPurchaseError(error) // unexpected: network, native module, runtime
showPurchaseFailedMessage()
}Using client before configureSubKit(...) also throws — configuration order
is a programming error, not a runtime state.
The retryable flag
SubKitSerializableError.retryable tells you whether retrying can help:
retryable: true— transient store/network trouble (e.g.store_unavailable). Offer a retry button.retryable: false— a precondition is missing (e.g.missing_identity,product_unavailable). Fix the cause instead: identify the user, reload offerings, or hide the package.
Normalized IAP errors
Raw expo-iap errors are inconsistent across platforms. The exported
normalizeIapError(error) produces a stable
{ code?, message, raw } shape — useful when you build custom flows on the
adapter level. Store-sheet cancellations are detected from the native error and
normalized to the cancelled purchase status, so you rarely handle them
yourself.
Access lifecycle errors
Refresh failures never erase still-valid cached access. The access hook keeps transport lifecycle separate from commercial decisions:
import { useSubKitAccess } from '@piparotech/subkit-expo'
const access = useSubKitAccess('pro')
if (access.state === 'granted' && access.evidence.freshness === 'offline') {
// bounded cached access remains usable
}
if (access.state === 'offline_unavailable') {
// network unavailable and no usable cache — keep access locked
}
if (access.state === 'error') {
reportPurchaseError(access.error)
}Calling access.refresh() resolves to the latest union even when refresh
fails, so recovery UI can switch on state rather than reconstructing policy.
Redaction guarantees
SDK-generated errors never contain bearer tokens, receipts, purchase tokens, or raw store payloads. You can log them safely.