In-App Subscriptions in Flutter: The Purchase Callback Is Not Proof of Payment
A local isPro boolean is forgeable, lost on reinstall and never expires. Purchase tokens, server-side verification, and the three-day window that silently refunds Android purchases.
Almost every in-app purchase tutorial ends at the same line. The purchase stream emits, the status is purchased, and the app writes a boolean:
InAppPurchase.instance.purchaseStream.listen((purchases) {
for (final purchase in purchases) {
if (purchase.status == PurchaseStatus.purchased) {
// This is the line. This is the whole bug.
prefs.setBool('isPro', true);
InAppPurchase.instance.completePurchase(purchase);
}
}
});It works. You will test it, the paywall will unlock, and you will ship it. Then over the following months it fails in three separate ways, none of which produce a crash report, an error log or a support ticket you can act on. A purchase callback is a UI event. It is not proof that anybody paid, and it is certainly not proof that they still are.
Three ways a local boolean is wrong
It is forgeable. SharedPreferences is an XML file in your app's data directory, and on a rooted device it is editable with a text editor. There are apps on the store whose entire purpose is flipping flags like this one. Nothing in your code can tell the difference between the file you wrote and the file somebody edited, because there is no difference.
It is lost. This is the one that costs you real goodwill rather than a few dollars. The user reinstalls, or gets a new phone, or installs your app on their tablet — and the boolean does not follow, because it never existed anywhere but that one device. They paid you, the store agrees they paid you, and your app shows them a paywall. That is the review that says scam, took my money, and they are not wrong from where they are sitting.
It never expires. A subscription is not an event, it is a state that changes without your app being involved: the card is declined, the user cancels from the Play Store, support issues a refund. Every one of those happens on Google's or Apple's servers while your app is closed. A boolean written eleven months ago knows none of it, so a user who cancelled in March still has Pro in December.
These are the same bug seen three ways. The entitlement is a fact about an account, and it is being stored as a fact about an installation.
The purchase token is the only thing worth keeping
The useful part of a PurchaseDetails is not the status. It is verificationData.serverVerificationData — a token you can hand to the store and ask, from your backend, what is actually true about it. The app's job is to relay that token and then wait.
Future<void> _handle(PurchaseDetails purchase) async {
if (purchase.status == PurchaseStatus.pending) {
// Slow payment methods land here for hours. Show progress,
// grant nothing.
_state.value = BillingState.pending;
return;
}
if (purchase.status == PurchaseStatus.purchased ||
purchase.status == PurchaseStatus.restored) {
final ok = await api.verifyPurchase(
productId: purchase.productID,
token: purchase.verificationData.serverVerificationData,
source: purchase.verificationData.source,
);
if (!ok) {
// Do NOT complete. An uncompleted purchase is redelivered on
// the next stream attach, so a transient server outage retries
// instead of silently swallowing a real payment.
_state.value = BillingState.verificationFailed;
return;
}
}
// Acknowledges on Android and finishes the transaction on iOS.
if (purchase.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchase);
}
}Note what is missing: the app never sets isPro. It does not know whether the user is Pro. It asks.
The ordering in that function is not cosmetic. An Android purchase that is not acknowledged within three days is automatically refunded and the entitlement revoked, so completePurchase cannot be skipped or deferred to a later session. But it also must not run before the server has recorded the purchase, because completing is what stops the platform from redelivering it. Verify, then complete — and if verification fails, leave it uncompleted so it comes back.
What the server does with it
Your backend has one question to answer: is this token a live subscription? Both stores expose an API for exactly that, and neither requires you to parse or trust anything the client sent.
On Android, call the Play Developer API with the purchase token. The useful field is subscriptionState, and it is far more informative than a boolean:
const purchase = await androidPublisher.purchases.subscriptionsv2.get({
packageName: "dev.devsnack.recall",
token,
});
// ACTIVE paying, or inside a free trial
// IN_GRACE_PERIOD payment failed, Google is retrying — keep access
// ON_HOLD retries exhausted — suspend access, keep the account
// CANCELED cancelled but paid through expiryTime — keep access
// EXPIRED over
const state = purchase.data.subscriptionState;
const expiry = purchase.data.lineItems.at(-1)?.expiryTime;
await db.entitlements.upsert({
userId,
productId,
state,
expiresAt: expiry,
// Store the token: the store notifies you about the token, not
// about your user, so this is the only way to map one to the other.
purchaseToken: token,
});On iOS the equivalent is the App Store Server API — you send the transaction id and get back signed transaction and renewal info as JWS. Verify the signature against Apple's root certificates. Decoding the payload without checking the signature is the iOS version of trusting the client, and the libraries Apple publishes do the verification for you.
Whatever the store says gets written to one row. From then on the app reads that row — on launch, on resume, wherever it currently reads prefs.getBool. Same call site, different source of truth, and now it works on the user's second device without anyone doing anything.
Most of the lifecycle happens while your app is closed
Verifying at purchase time fixes forgery and fixes reinstalls. It does not fix expiry, because your row is only as fresh as the last time somebody opened the app — and a user who cancelled has no particular reason to open it again.
Both stores will push the changes to you. On Google Play it is Real-time Developer Notifications: you create a Pub/Sub topic, paste it into the Play Console under Monetization setup, and Google posts every state change to an endpoint you own.
export async function POST(request: Request) {
const { message } = await request.json();
const notification = JSON.parse(
Buffer.from(message.data, "base64").toString()
);
const { purchaseToken, notificationType } =
notification.subscriptionNotification;
// Deliberately not trusting notificationType to describe the
// final state — Pub/Sub is at-least-once and unordered, so a RENEWED
// and a CANCELED can arrive out of order. Re-read the truth instead.
await refreshEntitlementFromPlay(purchaseToken);
// Always 200. A non-2xx makes Pub/Sub redeliver forever.
return new Response(null, { status: 200 });
}That comment is the part people learn the hard way. Notifications are delivered at least once and in no guaranteed order, so treating each one as an instruction — this one says cancelled, set cancelled — produces an entitlement that flickers and occasionally lands on the wrong value permanently. Treat the notification as a signal that something changed, and go ask the API what the state is now. It also makes the handler idempotent for free, which you need anyway.
Apple's App Store Server Notifications V2 work the same way: one HTTPS endpoint, signed payloads, and the same discipline of re-reading rather than believing.
Do not cut access the moment payment fails
A declined card is usually an expired card, not a decision to leave. Both platforms build in a recovery window, and honouring it is worth real money.
During a grace period the subscription has failed to renew but the store is still retrying and the user keeps access — Play reports IN_GRACE_PERIOD. This is the moment for an in-app banner asking them to update their payment method, and a large share of them do. If retries run out the subscription moves to account hold (ON_HOLD), and now access should stop — but the account and its history stay, because a hold still resolves into a renewal often enough to matter.
And CANCELED does not mean gone. It means they have turned off auto-renew and are paid up until expiryTime. Cutting them off at the moment they cancel takes away something they paid for, and it is a one-line mistake: gate on the expiry date, not on the cancellation flag.
Restore is not optional
There must be a visible way for a user to recover a purchase without buying again. On iOS this is explicit — App Review guideline 3.1.1 requires it, and a paywall with no restore control is a rejection, which is a week you did not plan for.
TextButton(
onPressed: () => InAppPurchase.instance.restorePurchases(),
child: const Text('Restore purchases'),
)restorePurchases re-emits past purchases through the same purchaseStream with status restored, which the handler above already covers — it verifies and completes them like any other. That is the payoff for routing everything through one function.
Worth knowing: this restores what the store account owns. If your app also has its own sign-in, the more reliable path is that signing in restores the entitlement from your own database, because that row is already keyed to the user rather than to a Play or Apple account they may have switched.
Testing it without waiting a month
Nobody can verify a renewal flow at real-world speed, which is why both stores compress it.
Add your account as a license tester in the Play Console and subscription periods collapse to minutes rather than weeks, with test subscriptions renewing a limited number of times before expiring on their own — enough to watch a full cycle in one sitting. Combine it with a test card that always declines and you can walk the grace period and account hold paths deliberately instead of hoping they are right. StoreKit sandbox accounts do the same on iOS.
Three things to actually check, because they are the ones that break in production:
1. Buy, force-stop the app before the callback runs, reopen.
→ the purchase must be redelivered and the entitlement appear
2. Buy on device A, sign in on device B.
→ Pro on B with no purchase and no restore tap
3. Cancel, then let the paid period lapse.
→ access holds until expiryTime, then stops without an app updateNumber one is the one that catches broken acknowledgement handling, and it is trivially easy to trigger — a user tapping away from the store sheet does the same thing.
The rule
There is one sentence underneath all of this: the client reports purchases, the server decides entitlements. Everything above follows from it. The app relays a token and reads a row. It does not compute, cache or reason about who is Pro, because it does not have the information required to be right — and the times it is wrong are the times you find out from a one-star review rather than from your own logs.
Recall AI — $19
A Flutter app with the subscription flow already built the way described here — purchase tokens verified server-side, entitlements read rather than decided, restore included.