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: show a last-chance offer, or nothing |
failed | No transaction happened. On a store purchase reason is "sdk" (the store reported a failure, or the SDK timed the purchase out) or "superseded" (a newer purchase for the same product reference, or a paywall re-open); web checkout failures carry no reason | Usually nothing; haptics.error() at most |
Platform note. A declined card or store error reaches the paywall as transaction_fail or transaction_timeout and resolves purchase() with reason: "sdk". The SDK owns that timing; the paywall runs no timer of its own, so a store sheet left open for five minutes is still a purchase in progress, not a failure.
- iOS: reported by the SDK.
- Android: not reported. The Android SDK sends no failure message, so a failed purchase leaves the promise pending and
isPurchasingtrueuntil the user abandons or completes. Design the buy button so that state is survivable (never a spinner that blocks the sheet), and do not gate irreversible UI onfailedthere.
Reacting to abandoned
abandoned is what your purchase() call resolves with when the user closes the store sheet; the same decline also arrives as a transaction_abandon event. Either makes a good hook for a last-chance offer:
const result = await purchase(selected);
if (result.status === "abandoned") {
router.push("offer", { transition: "sheet" }); // "sheet" is a transition you define in CSS
}The abandonment-offer example shows the full pattern.
Options
purchase(reference, { postPurchase?, stripeMetadata? })postPurchase overrides the config's postPurchase for this one button: "stay" keeps the paywall up after the sale, "dismiss" (the default) lets the SDK dismiss it, and on the web "redeem" or { redirect: url } send the shopper on. stripeMetadata is string key/values written onto the Stripe subscription once a web purchase completes; store purchases ignore it. See Web checkout.
The two channels
Your purchase() call is one channel. The SDK reporting on its own is the other, it reports what happened, whether or not this paywall started it: a purchase completing, a trial beginning, a sheet being abandoned.
// this paywall's own attempt
const result = await purchase("annual");
// anything the SDK reports, whoever started it
useSuperwallEvent("transaction_complete", () => haptics.success());
useSuperwallEvent("transaction_abandon", () => {});
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, with useHaptics() keyed to each outcome.
See Lifecycle & events for the full event list.
Restore
import { useActions, useHaptics } from "superwall/hooks";
const { restore } = useActions();
const haptics = useHaptics();
<button
onClick={async () => {
haptics.light();
const result = await restore();
if (result.status === "failed") setMessage("We couldn't find a purchase to restore.");
}}
>
Restore purchases
</button>restore() resolves once the SDK has finished:
| Result | Means |
|---|---|
{ status: "restored" } | Something was restored and entitlements are now active |
{ status: "failed" } | The restore failed, or there was nothing to restore |
Those two failures are one outcome on purpose, because the SDK can't tell them apart to the paywall. It composes a message for the "nothing to restore" case — "the restoration result is restored but there are no active entitlements" — but that text goes to its own logs and native alert, never over the protocol. Write copy that covers both, like "We couldn't find a purchase to restore."
The same lifecycle also lands on useSuperwallSnapshot().restore as started, completed or failed, and as the restore_start, restore_complete and restore_fail events, if you'd rather render progress than await.
- iOS: reports all three.
- Android: reports only
restore_fail.
Every store paywall should offer restore, App Review expects it.
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?