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 returned, sorted by slug, with
prices formatted for the user's storefront.
import SwiftUI
import Voidhash
struct PriceLabel: View {
@State private var price = ""
var body: some View {
Text(price).task {
let products = (try? await Voidhash.shared?.getProducts()) ?? []
price = products.first { $0.slug == "pro_monthly" }?.displayPrice ?? ""
}
}
}Each VoidhashProduct has id, slug, name, displayName, description, displayPrice,
price, currency, type, interval and providerProductId, the App Store product ID. A product
without an App Store mapping, or one the App Store doesn't return, is left out. See
Products are empty.
Start a purchase
purchase(product:) shows the App Store sheet. Cancellation and pending approval throw, so handle
them apart from real failures:
import Voidhash
import VoidhashCore
func buy(_ product: VoidhashProduct) async {
guard let voidhash = Voidhash.shared else { return }
do {
try await voidhash.purchase(product: product)
} catch let error as VoidhashStoreError where error.code == "USER_CANCELLED" {
// The user closed the App Store sheet.
} catch let error as VoidhashStoreError where error.code == "PURCHASE_PENDING" {
// Ask to Buy or another approval is pending. Access arrives once it clears.
} catch {
print("Purchase failed: \(error)")
}
}USER_CANCELLED also covers a dismissed App Store sign-in. purchase(productSlug:) buys by slug
and throws INVALID_PRODUCT_ID when the App Store sells no product with that slug. See
Errors for every code.
Both return a PurchaseOutcome:
| Outcome | Meaning |
|---|---|
.completed | Voidhash accepted the purchase and the person was refreshed. |
.deferred | The App Store charged the user, but Voidhash didn't answer within 10 seconds. The SDK 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 SDK also listens to Transaction.updates. Renewals, approved Ask to Buy requests 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.
Promotional offers
Pass the offer ID your product mapping names to buy a promotional offer:
try await voidhash.purchase(product: product, offerId: "winback_50")When the 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. Offers are set up
in Offers.
Restore purchases
Connect your Restore purchases button to restorePurchases():
try await voidhash.restorePurchases()
let hasPremium = try await voidhash.getCurrentPerson()?.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: read the person afterwards.
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
Present the App Store's offer code redemption and subscription management sheets:
try await voidhash.presentCodeRedemptionSheet()
try await voidhash.showManageSubscriptions()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(product:) throws READ_ONLY_PURCHASE_NOT_ALLOWED.
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.