Refund
Handle refunds initiated by users or store-side actions. iOS supports in-app refund requests via StoreKit 2, while Android refunds are store-driven and require server-side detection.
TL;DR
- iOS 15+: Use
beginRefundRequestIOSto present an in-app refund sheet - Android: No client-side refund API. Auto-refunded after 3 days if not acknowledged
- Server-side: Subscribe to App Store Server Notifications V2 (Apple) and Real-time Developer Notifications (Google) to react to refunds
- Critical: Always revoke entitlements when a refund is detected
Platform Differences#
| Platform | Client API | Detection |
|---|---|---|
| iOS | beginRefundRequestIOS (iOS 15+) | App Store Server Notifications V2 (REFUND, REVOKE) |
| Android | None — store-driven | Real-time Developer Notifications — voidedPurchaseNotification for one-time products, subscriptionNotification.SUBSCRIPTION_REVOKED for subscriptions — plus server-side reconciliation via the Voided Purchases API |
Platform Implementation#
Overview#
iOS lets users request refunds directly from inside your app using StoreKit 2's refund sheet. The system handles the refund flow; your app receives the result via the returned status string.
- Requires iOS 15+
- Not available on tvOS
- The actual refund decision is made by Apple — the API only initiates the request
- For detection of approved refunds, use App Store Server Notifications V2
beginRefundRequestIOS#
Present the refund request sheet for a previously purchased product.
import OpenIap
let status = try await OpenIapModule.shared.beginRefundRequestIOS(sku: purchase.productId)
if let status {
switch status {
case "success":
print("Refund request submitted")
case "userCancelled":
print("User cancelled refund flow")
default:
print("Refund request status: \(status)")
}
} else {
print("Refund request status: nil")
}App Store Server Notifications V2#
Apple sends a server-to-server notification when a refund is approved. Subscribe to handle revocation reliably — the in-app status is just the request, not the final outcome.
| notificationType | Meaning |
|---|---|
REFUND | Apple refunded the user |
REFUND_DECLINED | Refund was declined |
REVOKE | Family Sharing access revoked (treat like a refund) |
CONSUMPTION_REQUEST | Apple wants consumption data to decide on a refund — respond within 12 hours |
// Server webhook handler (Node.js).
// In App Store Server Notifications V2, signedTransactionInfo is itself a
// signed JWS string — verify and decode it to read its fields.
app.post('/webhooks/apple', async (req, res) => {
const { signedPayload } = req.body;
const decoded = await verifyAndDecodeJWS(signedPayload);
if (decoded.notificationType === 'REFUND' || decoded.notificationType === 'REVOKE') {
const transactionInfo = await verifyAndDecodeJWS(
decoded.data.signedTransactionInfo,
);
await revokeEntitlement(transactionInfo.transactionId);
}
res.sendStatus(200);
});Testing#
beginRefundRequestIOScan be exercised in sandbox but the sheet may show limited UI- Use StoreKit's "Refund" command in Xcode StoreKit Configuration to simulate refunds
- Configure the sandbox URL for App Store Server Notifications in App Store Connect
Revoking Entitlements#
When a refund is detected — through a webhook, polling, or a manual process — revoke the user's entitlement and clean up downstream state.
async function revokeEntitlement(transactionId: string) {
// 1. Mark entitlement inactive in your database
await db.entitlements.update({
where: { transactionId },
data: { status: 'refunded', revokedAt: new Date() },
});
// 2. Restrict access in any cached/session state
await invalidateUserSessionsForTransaction(transactionId);
// 3. (Optional) Notify the user out-of-band
await sendRefundConfirmationEmail(transactionId);
}Related#
Native References#
- Apple · Transaction.beginRefundRequest(in:) — StoreKit 2 in-app refund sheet
- Apple · App Store Server Notifications V2 — server-to-server REFUND / REVOKE events
- Google · Issue refunds — Play Console refund flow
- Google · Voided Purchases API — fallback polling for voided orders