Report and restore purchases

Report purchases from your existing billing integration to Voidhash.

The initial release runs in observer mode. Your existing billing integration starts purchases, restores access, and finishes or acknowledges store transactions. Voidhash reports the purchases it discovers without taking over those responsibilities. SDK-started purchases and hosted paywalls are temporarily unavailable; purchase(product:) throws READ_ONLY_PURCHASE_NOT_ALLOWED before opening a store purchase flow.

Report a completed purchase or restore

Call reportTransaction(...) after each successful purchase, including purchases from another SDK's paywall. Report each transaction returned by a restore callback as well. Configure Voidhash and identify the purchasing user before starting the host purchase flow.

Only the store identifier is required: an Apple transaction ID or a Google Play purchase token. Voidhash supplies its configured bundle/package and captures the current SDK identity automatically. The backend fetches and verifies the purchase with the store; timestamps, quantities, product metadata, receipts, signatures and purchaser arguments are unnecessary.

try await voidhash.reportTransaction(transactionId: transactionId)

The transaction ID is a string; preserve every digit. You can also pass a StoreKit.Transaction or VerificationResult<StoreKit.Transaction> directly to reportTransaction(...). With StoreKit 2, report the verified transaction and then finish it as before:

guard case .success(let verification) = try await product.purchase(),
    case .verified(let transaction) = verification
else {
    return
}

try await voidhash.reportTransaction(transaction)
await transaction.finish()

The SDK also listens to Transaction.updates and reports renewals, Ask to Buy approvals and other transactions StoreKit delivers later. Your own listener still finishes them.

reportTransaction sends only the ID. It keeps the original transaction ID, purchase date and revocation date on the device to recognise receipts Voidhash already accepted, and discards raw JSON and signed receipts. The verified-result overload rejects unverified results. The direct transaction overload leaves local verification with the host.

Existing VoidhashTransaction inputs remain supported; only transactionId is required. Other metadata is optional; originalTransactionId, purchaseDate and revocationDate stay on the device as above. Pending results are ignored. Invalid IDs throw INVALID_TRANSACTION. Reporting does not require a StoreKit connection; delivery failures stay queued without throwing into the host purchase callback.

Transactions from a local StoreKit configuration in Xcode are not reported, because the App Store cannot verify them. The SDK reports this once per session as the STOREKIT_TESTING_TRANSACTION_SKIPPED diagnostic. Test purchases with a sandbox account instead.

Reporting works after the host has finished or consumed a purchase because it does not scan the store. It never finishes, acknowledges or consumes transactions. Keep your billing integration's finalization and access checks in place.

A successful report is captured in the durable outbox before returning; delivery runs in the background. Success does not imply immediate backend acceptance. SDK diagnostics describe deferred delivery. While the app is in the foreground, the SDK retries a failed delivery on its own once the retry delay is over, and flush() retries a deferred report right away. Duplicate reports and observer callbacks use the same store identifier and retain the original captured identity through retries and app relaunches. A receipt Voidhash already accepted is not sent again, while a refund or Family Sharing revocation of it is. If the report cannot be written to disk, because the device is out of storage or has not been unlocked since a reboot, the call still returns normally. The SDK keeps the receipt in memory, retries the write and reports a TRANSACTION_CAPTURE_NOT_DURABLE diagnostic.

If Voidhash can never accept a receipt as sent, for example because the App Store is not configured for your app's bundle ID or the App Store does not know the transaction, the SDK keeps the receipt and sends it again on the next launch, after 6 hours, or when the user restores purchases. It reports the refusal once per receipt as the TRANSACTION_REJECTED diagnostic, with the reason.

Recovery scans and restore callbacks

Use syncPurchases() for silent recovery when the host callback exposes no usable store transaction values, including after its restore flow. It reports purchases the store still exposes, skips receipts already accepted by Voidhash, and refreshes the current person. Renewals and revocations are new receipts, so they are sent. It does not prompt for store authentication. Store-read failures reach the caller; delivery failures remain queued.

The SDK saves every receipt the scan finds before it sends the first one. The call then waits for Voidhash for at most one request timeout (10 seconds). When Voidhash answers in time, the SDK refreshes the person before the call returns, so a getCurrentPerson() right after it already includes the new purchases. When Voidhash is slow or unreachable, the call returns anyway and the receipts keep going in the background.

try await voidhash.syncPurchases()

Initialization installs the observer and scans purchases in the background. Foreground and connectivity recovery also scan with a one-minute throttle per trigger. Scans send only what Voidhash has not accepted yet. In observer mode, scans can report unconsumed or unfinished consumables that remain visible without finalizing them. They cannot recover consumed or finished consumables, or arbitrary expired subscription history. iOS scans pending transactions and current entitlements; Android queries currently owned purchases.

syncPurchases() is a fallback, not a delivery guarantee. If the host hides transaction values and finishes a consumable before Voidhash captures it, a later callback or scan cannot recover it. Keep explicit reporting in the successful purchase callback whenever your host SDK exposes the values.

Load products

getProducts() returns the store-backed products configured in your project. Prices come already formatted for the customer's storefront, so you can render them directly.

let products = try await voidhash.getProducts()
let monthly = products.first(where: { $0.slug == "monthly" })

if let monthly {
    Text(monthly.displayPrice)
}

If a product is missing from the result, the store did not return it. That happens when the product is not available on the current store account, environment, or provider configuration.

Explicit restoration

Connect your Restore Purchases button to restorePurchases(). It revalidates restorable purchases for the identity that requested the restore, even when Voidhash already accepted those receipts. The project's transfer policy controls whether ownership changes. A queued purchase retains its original buyer and must be delivered before the restore can request another owner.

Only restorePurchases() applies the transfer policy. Reports, recovery scans, observed renewals and syncPurchases() record a purchase another user already owns for that user and never move it, so signing out, or another account signing in with the same Apple ID, cannot take a subscription away from its owner. Because the user asked for it, a restore sends its receipts at once, including one waiting to be retried or refused before, even right after an outage.

try await voidhash.restorePurchases()

This calls AppStore.sync() and reads verified non-consumable transaction history, including expired or revoked transactions. App Store authentication may appear, so call it only from a user action. The SDK sends the latest transaction of each subscription or purchase, not every renewal in its history. Known consumables are excluded from explicit restoration. A store error or a receipt that cannot be accepted fails the restore with RECONCILE_TRANSACTIONS_FAILED; deferred receipts stay queued for retry. An empty store can restore successfully.

After the store returns its history, a restore waits for Voidhash for at most two request timeouts (20 seconds). If a receipt cannot reach Voidhash, the restore fails at once instead of sending the remaining receipts into the same outage, and the customer can try again later. On success, the SDK refreshes the person before restorePurchases() returns. Success does not imply an active entitlement: read the person right after the restore and use its current grants to decide whether to unlock access.

Reporting and silent sync after a host restore discover purchases. Use explicit restoration when Voidhash must revalidate cached receipts or apply its ownership policy for the current user.

Transactions missing from the dashboard

  • Confirm successful purchase callbacks call reportTransaction(...) with the original store values. Use syncPurchases() for restore-only callbacks that expose no transaction values.
  • Verify that Voidhash uses the intended project key and purchasing user, and that the dashboard is showing the matching project, person, and store environment.
  • Check SDK diagnostics for store-read or delivery failures. Analytics events arriving does not prove that a store transaction was discovered or submitted.
  • For scan-only integrations, confirm the purchase is still exposed by the store. A finished or consumed consumable requires captured transaction values; a successful host purchase alone is insufficient for a later scan.

Next steps