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 beginRefundRequestIOS to 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#

PlatformClient APIDetection
iOSbeginRefundRequestIOS (iOS 15+)App Store Server Notifications V2 (REFUND, REVOKE)
AndroidNone — store-drivenReal-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.

swift
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.

notificationTypeMeaning
REFUNDApple refunded the user
REFUND_DECLINEDRefund was declined
REVOKEFamily Sharing access revoked (treat like a refund)
CONSUMPTION_REQUESTApple wants consumption data to decide on a refund — respond within 12 hours
ts
// 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#

  • beginRefundRequestIOS can 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.

ts
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);
}

Native References#