Sell in-app products and subscriptions on iOS with StoreKit 2, and deliver what people bought
One.Purchases sells in-app products on iOS through StoreKit 2. It loads
products and their local prices, runs Apple’s purchase sheet, reports
transactions as they arrive, and lets you finish each one after you deliver
it. Android and web have no store yet: products load empty and purchases
reject.
To draw Apple’s own product and subscription screens instead of your own, use iOS Store Views. Both read the same transactions.
Products come from App Store Connect for a released app, or from a StoreKit configuration file selected in your Xcode scheme while you develop. No app config or usage string is needed.
import { useEffect, useState } from 'react'import { Button } from 'react-native'import { One } from 'one'
const premiumId = 'com.example.app.premium'
function UpgradeButton({ onUnlocked }: { onUnlocked: () => void }) { const [price, setPrice] = useState<string | null>(null) useEffect(() => { One.Purchases.getProducts([premiumId]).then(([product]) => setPrice(product?.displayPrice ?? null)) }, []) if (!price) return null return ( <Button title={`Unlock for ${price}`} onPress={async () => { const result = await One.Purchases.purchase(premiumId) if (result.status !== 'purchased' || !result.transaction) return onUnlocked() await One.Purchases.finishTransaction(result.transaction.id) }} /> )}The button shows the price StoreKit returns, already formatted for the person’s store and currency, and finishes the transaction only after the unlock is delivered.
Products. getProducts asks the App Store for up to 100 product IDs and
returns the ones it found, in the order you asked. IDs it does not know are
left out rather than failing the call, so a typo shows up as a missing
product.
Purchasing. purchase looks the product up again and opens Apple’s
purchase sheet, which handles sign in, payment, and Face ID. It resolves
purchased with a transaction, cancelled when people back out, or
pending when the purchase waits on something outside the app, such as
Ask to Buy approval from a parent.
Verification. StoreKit signs every transaction and checks the signature
on the device. One only hands you transactions that pass that check;
a purchase that fails it rejects with E_PURCHASE_UNVERIFIED. Each
transaction also carries its signed form as jws, which your server can
verify with Apple before it grants anything stored on the server.
Finishing. A transaction stays unfinished until you call
finishTransaction. StoreKit keeps offering unfinished transactions, in
getUnfinishedTransactions and to listeners at launch, so a crash between
payment and delivery never loses a purchase. Finish only after you have
delivered.
Updates. addTransactionListener hears transactions that arrive outside
a purchase call: approved Ask to Buy requests, subscription renewals,
purchases made on another device, and refunds. All listeners share one
StoreKit stream, which stops when the last listener is removed. An update
that fails verification arrives with status unverified, an error message,
and no transaction.
Entitlements. getCurrentEntitlements returns the latest transaction for
each non-consumable people own, each active subscription, and each
non-renewing subscription. Refunded purchases and consumables never appear
there.
Restoring. sync asks the App Store to bring this device up to date
with the account’s purchase history. It may ask people to sign in, so call
it only from a Restore Purchases button.
| Function | Resolves |
|---|---|
| getProducts(ids) | The products found, in order |
| purchase(id, accountToken?) | The purchase result |
| getCurrentEntitlements() | Owned and active transactions |
| getUnfinishedTransactions() | Transactions not yet finished |
| finishTransaction(id) | When StoreKit has finished it |
| addTransactionListener(onUpdate) | A function that removes it, at once |
| sync() | When the App Store sync ends |
accountToken is an optional UUID that StoreKit stores with the purchase
and returns in its signed form, so your server can match it to an account.
| PurchaseProduct | Type | Description |
|---|---|---|
idstringThe product ID from App Store Connect. | string | The product ID from App Store Connect. |
typePurchaseProductTypeconsumable, nonConsumable, nonRenewable, autoRenewable, or other. | PurchaseProductType | consumable, nonConsumable, nonRenewable, autoRenewable, or other. |
displayNamestringThe localized name. | string | The localized name. |
descriptionstringThe localized description. | string | The localized description. |
displayPricestringThe price, formatted for the store. | string | The price, formatted for the store. |
currencyCodestringThe ISO currency code, when known. | string | The ISO currency code, when known. |
| PurchaseTransaction | Type | Description |
|---|---|---|
idstringThe transaction ID, as a decimal string. | string | The transaction ID, as a decimal string. |
originalIdstringThe first transaction of a subscription or restore. | string | The first transaction of a subscription or restore. |
productIdstringWhich product was bought. | string | Which product was bought. |
productTypePurchaseProductTypeThe product type. | PurchaseProductType | The product type. |
purchaseDateMsnumberWhen it was bought, in Unix milliseconds. | number | When it was bought, in Unix milliseconds. |
expirationDateMsnumberWhen a subscription period ends. | number | When a subscription period ends. |
revocationDateMsnumberWhen Apple refunded or revoked it. | number | When Apple refunded or revoked it. |
jwsstringThe signed transaction for server checks. | string | The signed transaction for server checks. |
Transaction IDs are strings because StoreKit uses 64-bit numbers, which JavaScript numbers cannot always hold exactly.
| Code | Means |
|---|---|
| E_PURCHASE_INPUT | Bad IDs or a token that is not a UUID |
| E_PURCHASE_PRODUCTS | The App Store request failed |
| E_PURCHASE_PRODUCT | The product to buy was not found |
| E_PURCHASE_INACTIVE | The app was not in front |
| E_PURCHASE_FAILED | The purchase failed |
| E_PURCHASE_UNVERIFIED | A transaction failed verification |
| E_PURCHASE_NOT_UNFINISHED | No unfinished transaction has that ID |
| E_PURCHASE_SYNC | The restore failed or was cancelled |
| iOS | Android | Web | |
|---|---|---|---|
| Store | StoreKit 2 | None yet | None |
| Products | From the App Store | Empty | Empty |
| Listener | StoreKit updates | Never called | Never called |
| Other calls | Work | Reject | Reject |
purchase,
the listener, and getUnfinishedTransactions. Record delivery by
transaction ID so a second sighting does nothing.pending result has no transaction. The
approved purchase arrives later through the listener, possibly after a
relaunch.getCurrentEntitlements rejects with
E_PURCHASE_UNVERIFIED instead of returning the rest.purchase checks that the app is active
before it opens the sheet, and rejects with E_PURCHASE_INACTIVE
otherwise.import { One } from 'one'
type Transaction = { id: string; productId: string; jws: string }
const delivering = new Set<string>()
async function deliver(transaction: Transaction, grant: (transaction: Transaction) => Promise<void>) { if (delivering.has(transaction.id)) return delivering.add(transaction.id) try { await grant(transaction) await One.Purchases.finishTransaction(transaction.id) } finally { delivering.delete(transaction.id) }}
export async function startStore(grant: (transaction: Transaction) => Promise<void>) { const remove = One.Purchases.addTransactionListener((update) => { if (update.transaction) deliver(update.transaction, grant) }) for (const transaction of await One.Purchases.getUnfinishedTransactions()) { await deliver(transaction, grant) } return remove}Call startStore once at launch and route purchase results through the
same deliver. grant should check jws with your server and record the
transaction ID, so a transaction seen twice is granted once.
import { Alert } from 'react-native'import { One } from 'one'
async function restore(unlock: (productIds: string[]) => void) { try { await One.Purchases.sync() } catch { return } const owned = await One.Purchases.getCurrentEntitlements() unlock(owned.map((transaction) => transaction.productId)) if (owned.length === 0) Alert.alert('Nothing to restore', 'This account has no purchases.')}sync rejects when people cancel the sign in, so the function stops
quietly. Reading entitlements afterward picks up purchases made on other
devices.
Edit this page on GitHub.