Make and restore purchases
Buy products, restore purchases, and report purchases another billing SDK makes.
The Swift SDK buys products through StoreKit 2, syncs each transaction with Voidhash, and only then finishes it with StoreKit. Paywalls handle purchases and restores for you; use the methods on this page when you build your own purchase screen.
Load products
getProducts() returns your project's products that the App Store sells, sorted by slug.
getProducts(slugs:) returns the ones you name, in that order, with nil for a slug the App Store
doesn't sell.
import SwiftUI
import Voidhash
struct PriceLabel: View {
@State private var label = ""
var body: some View {
Text(label).task {
guard let voidhash = Voidhash.shared,
let products = try? await voidhash.getProducts(slugs: ["pro_annual"]),
let product = products.first ?? nil,
let subscription = product.subscription
else { return }
// "$49.99 a year, $4.17 a month"
label = "\(product.price.formatted) a year, \(subscription.pricePerMonth.formatted) a month"
}
}
}Each VoidhashProduct has:
| Property | What it is |
|---|---|
productId | The Voidhash product ID, which the person's subscriptions and purchases name. |
slug, name | The product's slug and name in Studio. id is the slug. |
storeProductId | The App Store product ID. |
type | subscription, consumable, nonConsumable or nonRenewingSubscription. |
displayName, description | The App Store's localized title and description. |
price | The regular price: an exact Decimal amount, amountMicros, currencyCode, and formatted for the storefront. |
subscription | For subscriptions only: the renewal period, groupId, introOffer, and pricePerDay to pricePerYear. |
offers | The promotional and win-back offers this user can have. |
appStore | Everything StoreKit says about the product, including the StoreKit.Product itself. |
Show formatted as it is; it's the App Store's own string for the user's storefront. A period is a
unit and a count; 7 days read as 1 week. Prices per unit count a year as 365 days, 52 weeks or
12 months and round to the currency's cents, so the same plan shows the same per-month price on iOS
and Android.
A product the App Store doesn't return is left out and reported once per call as the
PRODUCT_NOT_FOUND diagnostic, with its slug and why; so is a slug you asked for that has no App
Store mapping (getProducts() leaves those out without a report). So is a product
the App Store sells as a different type than Studio, reported as PRODUCT_TYPE_MISMATCH: a
subscription in Studio needs an auto-renewable subscription in App Store Connect, a consumable a
consumable, and a one-time product a non-consumable or a non-renewing subscription. See
Products are empty.
Offers
subscription.introOffer is the free trial or introductory price the user is eligible for right
now, or nil. offers lists promotional offers to current and former subscribers of the
subscription group, as the App Store sells them: users no longer eligible for the introductory offer,
or with a subscription in the group (bought at the regular price, for example), and, from iOS 18,
the win-back offers the App Store says the user can have. Only the promotional offers you add to the
product's App Store mapping in Studio are listed, because Voidhash signs only those. Each offer has
an id (the offer ID you gave it in Studio, or a win-back offer's App Store offer ID), a kind
and its pricing phases, each with a payment mode, a price per cycle, the length of a cycle and the number
of cycles. appStore lists every offer, whatever the user's eligibility.
Eligibility changes with purchases and identities; every getProducts call asks the App Store
again.
Start a purchase
purchase(_:options:) shows the App Store sheet. It returns how the purchase ended; only real
failures throw:
import Voidhash
import VoidhashCore
func buy(_ product: VoidhashProduct) async {
guard let voidhash = Voidhash.shared else { return }
do {
switch try await voidhash.purchase(product) {
case .purchased(let transaction, let synced):
// The App Store charged the user. `synced` is false while Voidhash hasn't accepted the
// receipt yet; the SDK keeps sending it.
print("Bought \(transaction.productSlug ?? transaction.storeProductId)")
case .pending:
// Ask to Buy or another approval is pending. It arrives through transactionUpdates().
break
case .cancelled:
// The user closed the App Store sheet.
break
case .disabled:
// The SDK is disabled; nothing was bought.
break
}
} catch {
print("Purchase failed: \(error)")
}
}By default a subscription is bought with its introductory offer when the user can have it. Pass
VoidhashPurchaseOptions to choose otherwise:
| Option | What it does |
|---|---|
offer: .automatic | The introductory offer when the user can have it, otherwise the regular price. The default. |
offer: .none | The regular price. The App Store still applies an introductory offer the user is eligible for. |
offer: .offer(offer), .id("id") | One of the product's offers or its introOffer. .id takes the offer ID from Studio, or the App Store's. |
quantity | How many to buy; more than 1 only for a consumable. |
appStore.simulatesAskToBuyInSandbox | Makes a sandbox purchase wait for approval, to test pending purchases. |
appStore.confirmIn | .scene(windowScene) or .viewController(controller) to show the App Store's confirmation on (a view controller from iOS 18.2). |
_ = try await voidhash.purchase(product, options: VoidhashPurchaseOptions(offer: .id("winback_50")))Voidhash signs promotional offers for the person; set up the In-App Purchase key in
Offers. Win-back offers need iOS 18. When an offer can't be applied, for
example because the user isn't eligible or Voidhash can't be reached to sign it, the call throws
OFFER_UNAVAILABLE and buys nothing. It never falls back to the regular price. When the App Store
refused the offer because the user isn't eligible for it, the error's reason is
.ineligibleForOffer; load the products again before offering another purchase.
Only one purchase runs at a time: a second one while the first is open throws
PURCHASE_IN_PROGRESS. A purchase started on the App Store meanwhile waits for the first to end. See Errors for every code.
Once the App Store has charged the user, the call waits up to 10 seconds for Voidhash. If Voidhash
doesn't answer in time, it returns synced: false, keeps the receipt, sends it in the background
and finishes the transaction once Voidhash accepts it.
Unlock features from the person's perks, not from the call returning. See Check access.
The returned VoidhashTransaction has the App Store transaction id, its series' originalId,
the productSlug and storeProductId, purchasedAt, expiresAt, quantity, whether it
isRenewal (iOS 17), the offer it was bought with, its environment, and an appStore part
with the ownership type, the storefront country (two letters, such as US), upgrade and refund
details and the signed jwsRepresentation.
Purchases that complete later
The SDK listens to Transaction.updates. Renewals, approved Ask to Buy requests, redeemed offer
codes and purchases made on another device or while the app was closed are synced and finished on
their own. Don't finish transactions in your own code as well. A transaction StoreKit can't verify
is neither synced nor finished; the SDK reports it once as STOREKIT_TRANSACTION_UNVERIFIED.
To update your screen when one completes, listen to transactionUpdates():
import SwiftUI
import Voidhash
struct PurchaseActivityView: View {
var body: some View {
Text("Purchases")
.task {
guard let voidhash = Voidhash.shared else { return }
for await update in voidhash.transactionUpdates() {
print("\(update.transaction.productSlug ?? "") completed")
}
}
}
}An update arrives once the SDK has handled its transaction. synced is true when Voidhash
accepted the receipt and false while the receipt is queued on the device; the SDK keeps sending
it. It's a signal to refresh your screen, not a guarantee of access: read access from the person,
which personUpdates() delivers each time it changes, starting with the one saved on the device.
Each call to either method gives a stream of its own. Purchases that purchase returned don't
arrive again, and refunds, revocations and upgraded-from subscriptions don't arrive at all: they
show in the person. Each transaction arrives once per app session; one Voidhash hasn't accepted yet
can arrive again after the app relaunches. Up to 10 that complete before you listen are kept for
your first stream.
Purchases started on the App Store
When a user buys a promoted in-app purchase or a win-back offer on the App Store, the SDK buys it
once it has started, so the purchase belongs to the restored user, and delivers it through
transactionUpdates(). To buy it yourself, for example after sign-in, set
options.purchaseIntents = .manual and listen to voidhash.appStore.purchaseIntents():
for await intent in voidhash.appStore.purchaseIntents() {
_ = try? await voidhash.purchase(intent.product, options: intent.purchaseOptions)
}Restore purchases
Connect your Restore purchases button to restorePurchases(). It returns the refreshed person:
let person = try await voidhash.restorePurchases()
let hasPremium = person?.hasActivePerk("premium") ?? falseIt calls AppStore.sync(), which may ask the user to sign in, so call it only from a user action.
It sends the latest transaction of each subscription and non-consumable to Voidhash, even ones
Voidhash already has, and refreshes the person before it returns. A successful restore doesn't mean
the user has access: check the person's perks. A dismissed sign-in throws USER_CANCELLED.
A restore waits up to 20 seconds for Voidhash. If a receipt can't reach Voidhash, it throws
RECONCILE_TRANSACTIONS_FAILED, keeps the receipts queued and sends them later. Consumables aren't
restored.
Only a restore can give the current user a purchase that already grants access to another person. Your project's paid access sharing setting decides whether both keep access, access moves, or it stays with the original person.
Store sheets and the App Store
try await voidhash.showManageSubscriptions(product: product) // opens the product's group (iOS 17)
let redeemed = try await voidhash.presentCodeRedemption() // offer codes (iOS 16)
let storefront = await voidhash.getStorefront() // storefront?.countryCode is "US"
let canPay = await voidhash.canMakePayments() // false when Screen Time blocks purchasesFrom iOS 27 the offer code sheet returns the redeemed transaction, already synced; on earlier
versions it arrives through transactionUpdates(). The person is refreshed after the subscription
management sheet closes.
voidhash.appStore reaches what only the App Store offers:
| Method | What it does |
|---|---|
beginRefundRequest(transactionId:) | Shows Apple's refund request sheet. Returns .success or .userCancelled. |
subscriptionStatus(groupId:) | StoreKit's renewal state and renewal info of each subscription in a group on this device, the user's first. |
currentEntitlements() | The transactions StoreKit lists as entitling the Apple account. |
appTransaction() | When and in which version the user first got the app, signed (iOS 16). |
messages(), display(_:) | App Store messages, with options.appStoreMessages = .manual. |
purchaseIntents() | Purchases started on the App Store, with options.purchaseIntents = .manual. |
App Store messages, such as a price increase to agree to, wait while a paywall is on screen and
show afterwards. One that arrives while no window is in the foreground shows when the app is next
active. Set options.appStoreMessages = .automatic to let the App Store show them at once.
For subscription status across devices and stores, read the person's subscriptions instead. See Check access.
Not supported
External purchase links and alternative billing, subscriptions with add-ons and purchases of several products at once, App Store pricing terms and billing plans, introductory offer eligibility overrides, and storefront change updates.
Report purchases from another billing SDK
In observer mode (options.readOnly = true), your existing billing SDK
starts, restores and finishes purchases. Voidhash reports StoreKit transactions without finishing
them, and purchase(_:options:) throws READ_ONLY_PURCHASE_NOT_ALLOWED. Purchases started on
the App Store are left to your billing SDK.
Configure Voidhash and identify the user before the purchase starts. After each successful purchase, report the transaction. Only its ID is sent; Voidhash verifies it with the App Store.
import StoreKit
import Voidhash
func buy(_ product: Product) async throws {
guard case .success(let verification) = try await product.purchase(),
case .verified(let transaction) = verification
else { return }
try await Voidhash.shared?.reportTransaction(transaction)
await transaction.finish()
}reportTransaction also accepts a VerificationResult<Transaction> (it throws for an unverified
one) or a transaction ID string: reportTransaction(transactionId:). Keep every digit of the ID.
An empty ID throws INVALID_TRANSACTION.
A report is written to disk before the call returns and is delivered in the background, so an outage never fails your purchase callback. Reports work after your SDK finished or consumed the transaction. Voidhash never finishes, acknowledges or consumes a transaction you reported, in or out of observer mode and across relaunches: it stays yours to finish.
Report the transactions your billing SDK's restore returns with isRestore: true, so they're
treated as a restore for the current user:
try await voidhash.reportTransaction(transaction, isRestore: true)When a callback gives you no transaction, call syncPurchases(). It reports the pending
transactions and current entitlements StoreKit still exposes, skips receipts Voidhash already
accepted, and refreshes the person if Voidhash answers within 10 seconds. It doesn't prompt for
sign-in. It can't recover a consumable your SDK already finished, so prefer reportTransaction.
In observer mode the SDK still listens to Transaction.updates and reports renewals and Ask to Buy
approvals; your own listener finishes them.
Receipts waiting for Voidhash
Every receipt goes to a queue on the device before it's sent and stays there until Voidhash accepts
it, across launches and outages. The SDK retries on its own, scans for missed transactions at
launch, and scans again when the app returns to the foreground or regains connectivity, at most
once a minute each. When Voidhash answers a receipt with Retry-After, every queued receipt waits
until then; only a restore the user asks for is sent sooner.
pendingTransactionCount() returns how many receipts are waiting. syncPendingWork() sends
everything queued now, without waiting out the retry delay: identify calls and attributes, then
receipts, then events. It waits up to 2 seconds and continues in the background.
if await voidhash.pendingTransactionCount() > 0 {
await voidhash.syncPendingWork()
}When Voidhash refuses a receipt for good, for example because the App Store isn't connected for
your bundle ID, the SDK reports TRANSACTION_REJECTED and tries again on the next launch, after
6 hours, or on a restore.
Purchases from an Xcode StoreKit configuration file aren't sent, because the App Store can't
verify them. The SDK reports STOREKIT_TESTING_TRANSACTION_SKIPPED once per session. Test with a
sandbox account or in development mode.
Next steps
- Check access
- Errors
- Test purchases (Help center)
- Troubleshooting
Something wrong or missing on this page? Tell us by email or on Discord.