Purchases
Make the sale with usePurchase — handle completed, abandoned, and failed outcomes, restore purchases, and react to transactions from anywhere.
A purchase is one call: pass a product reference, await the result, react to what happened. The SDK owns the store sheet, the payment, and the receipt.
import { usePurchase, useHaptics } from "superwall/hooks";
const { purchase } = usePurchase();
const haptics = useHaptics();
<button
onClick={async () => {
haptics.light();
const result = await purchase("annual");
if (result.status === "completed") haptics.success();
}}
>
Subscribe
</button>The three outcomes
purchase() resolves — it never throws for flow outcomes:
| Status | Meaning | Respond by |
|---|---|---|
completed | The sale went through | haptics.success(); the SDK dismisses the paywall if configured |
abandoned | The user closed the store sheet | Treat as an ordinary outcome — most people who open a sheet close it. This is the only place this paywall's own declined offer is visible: show a last-chance offer, or nothing |
failed | No transaction happened — reason is "timeout" or "superseded" (a retry or re-presentation replaced this attempt) | Usually nothing; haptics.error() at most |
Never put the buy button in a loading state
No "One moment…", no disabling, no spinner. The store sheet is the feedback, and the SDK owns when it appears. A button that visibly waits makes the paywall feel broken in the gap the platform already covers.
Abandoned is a signal, not a failure
Someone opened the sheet and closed it — that's the closest thing a paywall gets to hearing "not at this price." A common pattern is pushing a last-chance offer:
const result = await purchase(selected);
if (result.status === "abandoned") {
router.push("offer", { transition: "sheet" });
}One recovery offer, not two. If the user abandons the discounted offer as well, let them be. The abandonment-offer example shows the full pattern — a second product, not a second design.
Options
purchase(reference, { shouldDismiss?, timeoutMs? })Both default to what config.ts declares (dismissOnPurchase, purchaseTimeoutMs).
The two channels
Your purchase() call is one channel. The SDK reporting on its own is the other — and it reports transactions whoever started them. A successful restore arrives as a transaction_complete event with no purchase call in sight.
// this paywall's own attempt
const result = await purchase("annual");
// anything the SDK reports — purchase, restore, trial start
useSuperwallEvent("transaction_complete", () => haptics.success());
useSuperwallEvent("freeTrial_start", () => {});Drive this paywall's flow from the awaited result; use events for side effects that should fire on any transaction, however it started. The purchase-states example shows both channels side by side — and it's the one example that demonstrates the full haptic vocabulary (success() and error() keyed to outcomes).
See Lifecycle & events for the full event list.
Restore
import { useActions, useHaptics } from "superwall/hooks";
const { restore } = useActions();
<button onClick={() => { haptics.light(); restore(); }}>
Restore purchases
</button>restore() is fire-and-forget — there is no result to await. Success surfaces as a transaction_complete event or a dismissed paywall. Every store paywall should offer restore — App Review expects it.
Haptics on outcomes
iOS fires no feedback of its own inside a paywall, so the vocabulary is yours to supply:
haptics.light()when the buy button is tappedhaptics.success()when a transaction completes — via the event, so restores count toohaptics.error()sparingly, onfailed
Selling beyond the App Store
Trials — who's eligible, what to show each side — have their own page: Free trials. And a single config key sells the same paywall on the web through Stripe, with purchase() unchanged: Web checkout.
How is this guide?