Complete patterns for common flows. Every recipe names an entitlement and reads SubKit’s Effective Access decision. None reconstructs policy from raw CustomerInfo fields.
Gated screen
import { useSubKitAccess } from '@piparotech/subkit-expo'
export function ProFeatureScreen() {
const access = useSubKitAccess('pro')
if (access.state === 'loading') return <ScreenSkeleton />
if (access.state !== 'granted') {
return <Paywall onPurchaseFinished={access.refresh} />
}
return (
<>
{access.evidence.freshness === 'offline' ? <OfflineNotice /> : null}
<ProFeatureContent />
</>
)
}Paywall preflight
Force a fresh sync before asking a user to pay again:
import { client } from '@piparotech/subkit-expo'
async function openPaywall(navigate: (route: string) => void) {
try {
await client.syncPurchases({ reason: 'paywall_preflight' })
} catch {
// Best effort. The effective access read remains fail-closed.
}
const access = await client.getAccess('pro')
navigate(access.state === 'granted' ? '/pro' : '/paywall')
}Trial and billing-issue UI
Read commercial details only after the union proves effective access:
import { useSubKitAccess } from '@piparotech/subkit-expo'
export function SubscriptionBadge() {
const access = useSubKitAccess('pro')
if (access.state !== 'granted') return null
switch (access.entitlement.status) {
case 'trialing':
return <Badge tone="info" label={trialLabel(access.entitlement.expiresAt)} />
case 'grace_period':
case 'billing_retry':
return <Badge tone="warning" label="Payment issue — update your payment method" />
default:
return null
}
}
function trialLabel(expiresAt: string | null): string {
if (expiresAt == null) return 'Trial active'
const daysLeft = Math.max(0, Math.ceil((Date.parse(expiresAt) - Date.now()) / 86_400_000))
return `Trial — ${daysLeft} day${daysLeft === 1 ? '' : 's'} left`
}Restore and re-check
import { useState } from 'react'
import { client } from '@piparotech/subkit-expo'
export function RestorePurchasesButton() {
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
async function handleRestore() {
setBusy(true)
setMessage(null)
try {
await client.restorePurchases()
const access = await client.getAccess('pro')
setMessage(
access.state === 'granted'
? 'Purchases restored.'
: access.state === 'device_blocked'
? 'Purchase found. This device needs recovery.'
: 'No active purchase to restore.',
)
} catch {
setMessage('Restore failed. Check your connection and try again.')
} finally {
setBusy(false)
}
}
return <Button disabled={busy} onPress={handleRestore} label={message ?? 'Restore purchases'} />
}Native SubKit plus a separate web payment path
Apps may expose one app-specific facade while keeping payment authorities separate:
export function useProAccess(): boolean {
return useSubKitHasAccess('pro')
}import { useSubKitHasAccess } from '@piparotech/subkit-expo'Use a .web.ts implementation for Stripe or another web authority. Do not
copy SubKit’s entitlement/device/offline policy into the facade.