diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift
index 4d077821..ea17e6ec 100644
--- a/libraries/expo-iap/ios/ExpoIapHelper.swift
+++ b/libraries/expo-iap/ios/ExpoIapHelper.swift
@@ -21,7 +21,11 @@ final class IapException: GenericException<(code: String, message: String, produ
enum ExpoIapHelper {
// Disambiguate Subscription type to the one provided by OpenIAP
+ private static let listenerLock = NSRecursiveLock()
private static var listeners: [OpenIAP.Subscription] = []
+ private static var purchaseUpdatedSub: OpenIAP.Subscription?
+ private static var purchaseUpdatedHandler: ((Purchase) -> Void)?
+ private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] {
var result: [String: Any] = [:]
@@ -132,14 +136,14 @@ enum ExpoIapHelper {
promotedProduct: @escaping (String) async -> Void,
subscriptionBillingIssue: @escaping (Purchase) -> Void
) {
+ listenerLock.lock()
+ defer { listenerLock.unlock() }
+
// Clean up any existing listeners first
- cleanupListeners()
+ cleanupListenersLocked()
- let purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { purchase in
- Task { @MainActor in
- purchaseUpdated(purchase)
- }
- }
+ purchaseUpdatedHandler = purchaseUpdated
+ attachPurchaseUpdatedListenerLocked()
let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in
Task { @MainActor in
@@ -159,13 +163,54 @@ enum ExpoIapHelper {
}
}
- listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub, billingIssueSub]
+ listeners = [
+ purchaseErrorSub,
+ promotedProductSub,
+ billingIssueSub,
+ ]
+ }
+
+ static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) {
+ listenerLock.lock()
+ defer { listenerLock.unlock() }
+
+ purchaseUpdatedOptions = options ?? PurchaseUpdatedListenerOptions()
+ guard purchaseUpdatedHandler != nil else { return }
+ if let purchaseUpdatedSub {
+ OpenIapModule.shared.removeListener(purchaseUpdatedSub)
+ self.purchaseUpdatedSub = nil
+ }
+ attachPurchaseUpdatedListenerLocked()
+ }
+
+ private static func attachPurchaseUpdatedListenerLocked() {
+ guard let purchaseUpdatedHandler, purchaseUpdatedSub == nil else { return }
+
+ purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in
+ Task { @MainActor in
+ purchaseUpdatedHandler(purchase)
+ }
+ }, options: purchaseUpdatedOptions)
}
static func cleanupListeners() {
- // Clear subscriptions to prevent memory leaks
- // Subscription deinit will automatically call onRemove closure
+ listenerLock.lock()
+ defer { listenerLock.unlock() }
+
+ cleanupListenersLocked()
+ }
+
+ private static func cleanupListenersLocked() {
+ if let purchaseUpdatedSub {
+ OpenIapModule.shared.removeListener(purchaseUpdatedSub)
+ self.purchaseUpdatedSub = nil
+ }
+ for subscription in listeners {
+ OpenIapModule.shared.removeListener(subscription)
+ }
listeners.removeAll()
+ purchaseUpdatedHandler = nil
+ purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
}
static func setupStore(module: ExpoIapModule) {
diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift
index c220f614..1cbc807e 100644
--- a/libraries/expo-iap/ios/ExpoIapModule.swift
+++ b/libraries/expo-iap/ios/ExpoIapModule.swift
@@ -51,6 +51,14 @@ public final class ExpoIapModule: Module {
return succeeded
}
+ AsyncFunction("setPurchaseUpdatedListenerOptions") { (options: [String: Any]?) async throws -> Void in
+ let listenerOptions = PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS:
+ options?["dedupeTransactionIOS"] as? Bool
+ )
+ ExpoIapHelper.setPurchaseUpdatedListenerOptions(listenerOptions)
+ }
+
AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any]] in
ExpoIapLog.payload("fetchProducts", payload: params)
let request = try ExpoIapHelper.decodeProductRequest(from: params)
diff --git a/libraries/expo-iap/src/__mocks__/expo-modules-core.js b/libraries/expo-iap/src/__mocks__/expo-modules-core.js
index 3df7e636..8fb9c953 100644
--- a/libraries/expo-iap/src/__mocks__/expo-modules-core.js
+++ b/libraries/expo-iap/src/__mocks__/expo-modules-core.js
@@ -32,6 +32,7 @@ const mockNativeModule = {
verifyPurchaseWithProvider: jest.fn(),
initConnection: jest.fn(),
endConnection: jest.fn(),
+ setPurchaseUpdatedListenerOptions: jest.fn().mockResolvedValue(undefined),
// Android-specific methods
acknowledgePurchaseAndroid: jest.fn(),
consumeProductAndroid: jest.fn(),
@@ -40,6 +41,7 @@ const mockNativeModule = {
launchExternalLinkAndroid: jest.fn(),
createBillingProgramReportingDetailsAndroid: jest.fn(),
addListener: jest.fn(),
+ removeListener: jest.fn(),
removeListeners: jest.fn(),
};
diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts
index 3b822f45..a812f6e8 100644
--- a/libraries/expo-iap/src/__tests__/index.test.ts
+++ b/libraries/expo-iap/src/__tests__/index.test.ts
@@ -60,7 +60,7 @@ describe('Public API (index.ts)', () => {
it('registers purchase updated listener', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const fn = jest.fn();
- purchaseUpdatedListener(fn);
+ const subscription = purchaseUpdatedListener(fn);
expect(addListener).toHaveBeenCalledWith(
OpenIapEvent.PurchaseUpdated,
expect.any(Function),
@@ -69,6 +69,156 @@ describe('Public API (index.ts)', () => {
const event = {id: 't', productId: 'p', platform: 'IOS'} as any;
passed(event);
expect(fn).toHaveBeenCalledWith({...event, platform: 'ios'});
+ expect(typeof subscription.remove).toBe('function');
+ });
+
+ it('registers non-deduping purchase updated listener on iOS', () => {
+ const addListener = (ExpoIapModule as any).addListener as jest.Mock;
+ const setOptions = (ExpoIapModule as any)
+ .setPurchaseUpdatedListenerOptions as jest.Mock;
+ const fn = jest.fn();
+ const subscription = purchaseUpdatedListener(fn, {
+ dedupeTransactionIOS: false,
+ });
+ expect(setOptions).toHaveBeenCalledWith({
+ dedupeTransactionIOS: false,
+ });
+ expect(addListener).toHaveBeenCalledWith(
+ OpenIapEvent.PurchaseUpdated,
+ expect.any(Function),
+ );
+ subscription.remove();
+ });
+
+ it('removes listener through native subscription when available', () => {
+ const addListener = (ExpoIapModule as any).addListener as jest.Mock;
+ const nativeRemove = jest.fn();
+ addListener.mockReturnValueOnce({remove: nativeRemove});
+
+ const subscription = purchaseUpdatedListener(jest.fn());
+ subscription.remove();
+ subscription.remove();
+
+ expect(nativeRemove).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to native removeListener when addListener returns void', () => {
+ const addListener = (ExpoIapModule as any).addListener as jest.Mock;
+ const removeListener = (ExpoIapModule as any).removeListener as jest.Mock;
+ addListener.mockReturnValueOnce(undefined);
+
+ const subscription = purchaseUpdatedListener(jest.fn());
+ const nativeListener = addListener.mock.calls[0][1];
+ subscription.remove();
+ subscription.remove();
+
+ expect(removeListener).toHaveBeenCalledTimes(1);
+ expect(removeListener).toHaveBeenCalledWith(
+ OpenIapEvent.PurchaseUpdated,
+ nativeListener,
+ );
+ });
+
+ it('filters duplicate replay events for default listeners when a non-deduping listener is active', () => {
+ const addListener = (ExpoIapModule as any).addListener as jest.Mock;
+ const defaultListener = jest.fn();
+ const nonDedupingListener = jest.fn();
+
+ purchaseUpdatedListener(defaultListener);
+ const nonDedupingSubscription = purchaseUpdatedListener(
+ nonDedupingListener,
+ {
+ dedupeTransactionIOS: false,
+ },
+ );
+
+ const defaultHandler = addListener.mock.calls[0][1];
+ const nonDedupingHandler = addListener.mock.calls[1][1];
+ const event = {
+ id: 'expo-dedupe-replay',
+ productId: 'p',
+ platform: 'IOS',
+ } as any;
+
+ defaultHandler(event);
+ nonDedupingHandler(event);
+ defaultHandler(event);
+ nonDedupingHandler(event);
+
+ expect(defaultListener).toHaveBeenCalledTimes(1);
+ expect(nonDedupingListener).toHaveBeenCalledTimes(2);
+ nonDedupingSubscription.remove();
+ });
+
+ it('resets default listener duplicate history after endConnection', async () => {
+ const addListener = (ExpoIapModule as any).addListener as jest.Mock;
+ (ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true);
+ const listener = jest.fn();
+
+ purchaseUpdatedListener(listener);
+ const handler = addListener.mock.calls[0][1];
+ const event = {
+ id: 'expo-dedupe-after-reconnect',
+ productId: 'p',
+ platform: 'IOS',
+ } as any;
+
+ handler(event);
+ handler(event);
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ await endConnection();
+ handler(event);
+
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ it('reapplies non-deduping purchase updated option after reconnect', async () => {
+ (Platform as any).OS = 'ios';
+ (ExpoIapModule.initConnection as jest.Mock).mockResolvedValue(true);
+ (ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true);
+ const setOptions = (ExpoIapModule as any)
+ .setPurchaseUpdatedListenerOptions as jest.Mock;
+
+ const subscription = purchaseUpdatedListener(jest.fn(), {
+ dedupeTransactionIOS: false,
+ });
+ expect(setOptions).toHaveBeenLastCalledWith({
+ dedupeTransactionIOS: false,
+ });
+
+ setOptions.mockClear();
+ await endConnection();
+ await initConnection();
+
+ expect(setOptions).toHaveBeenCalledTimes(1);
+ expect(setOptions).toHaveBeenCalledWith({
+ dedupeTransactionIOS: false,
+ });
+ subscription.remove();
+ });
+
+ it('removes non-deduping purchase updated listeners idempotently', () => {
+ const setOptions = (ExpoIapModule as any)
+ .setPurchaseUpdatedListenerOptions as jest.Mock;
+
+ const firstSubscription = purchaseUpdatedListener(jest.fn(), {
+ dedupeTransactionIOS: false,
+ });
+ const secondSubscription = purchaseUpdatedListener(jest.fn(), {
+ dedupeTransactionIOS: false,
+ });
+
+ firstSubscription.remove();
+ firstSubscription.remove();
+ secondSubscription.remove();
+
+ expect(setOptions.mock.calls).toEqual([
+ [{dedupeTransactionIOS: false}],
+ [{dedupeTransactionIOS: false}],
+ [{dedupeTransactionIOS: false}],
+ [{dedupeTransactionIOS: true}],
+ ]);
});
it('registers purchase error listener', () => {
diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts
index bf37bf84..89b5b7d7 100644
--- a/libraries/expo-iap/src/index.ts
+++ b/libraries/expo-iap/src/index.ts
@@ -30,6 +30,7 @@ import type {
ProductSubscription,
Purchase,
PurchaseOptions,
+ PurchaseUpdatedListenerOptions,
QueryField,
RequestPurchasePropsByPlatforms,
RequestPurchaseAndroidProps,
@@ -94,19 +95,121 @@ type ExpoIapEmitter = {
): void;
};
+type NativePurchaseUpdatedOptionsModule = {
+ setPurchaseUpdatedListenerOptions?: (
+ options?: PurchaseUpdatedListenerOptions | null,
+ ) => Promise;
+};
+
// Use the raw native module for listener calls — JSI HostObjects require the
// real native module as `this` when calling addListener. Using a Proxy as
// `this` triggers "native state unsupported on Proxy" on New Architecture / Hermes.
// Resolved lazily so importing this module doesn't throw on unsupported platforms.
export const emitter: ExpoIapEmitter = {
addListener(eventName, listener) {
- return getNativeModule().addListener(eventName, listener);
+ const nativeModule = getNativeModule();
+ const nativeSubscription = nativeModule.addListener(eventName, listener);
+ let removed = false;
+
+ return {
+ remove: () => {
+ if (removed) {
+ return;
+ }
+ removed = true;
+
+ if (typeof nativeSubscription?.remove === 'function') {
+ nativeSubscription.remove();
+ return;
+ }
+
+ nativeModule.removeListener?.(eventName, listener);
+ },
+ };
},
removeListener(eventName, listener) {
- return getNativeModule().removeListener(eventName, listener);
+ return getNativeModule().removeListener?.(eventName, listener);
},
};
+let nonDedupingPurchaseUpdatedListenerCountIOS = 0;
+const purchaseUpdatedDedupeHistoryLimitIOS = 512;
+const purchaseUpdatedDedupeHistoryIOS = {
+ ids: new Set(),
+ order: [] as string[],
+ start: 0,
+};
+let purchaseUpdatedDedupeGenerationIOS = 0;
+
+type PurchaseUpdatedDedupeHistoryIOS = {
+ ids: Set;
+ order: string[];
+ start: number;
+};
+
+const purchaseUpdatedTransactionIdIOS = (purchase: Purchase) => {
+ if (typeof purchase.id === 'string' && purchase.id.length > 0) {
+ return purchase.id;
+ }
+ return null;
+};
+
+const rememberPurchaseUpdatedTransactionIOS = (
+ transactionId: string,
+ history: PurchaseUpdatedDedupeHistoryIOS,
+) => {
+ if (history.ids.has(transactionId)) {
+ return;
+ }
+
+ history.ids.add(transactionId);
+ history.order.push(transactionId);
+ if (
+ history.order.length - history.start >
+ purchaseUpdatedDedupeHistoryLimitIOS
+ ) {
+ const evicted = history.order[history.start];
+ history.start += 1;
+ if (evicted != null) {
+ history.ids.delete(evicted);
+ }
+ if (history.start > 128 && history.start * 2 > history.order.length) {
+ history.order = history.order.slice(history.start);
+ history.start = 0;
+ }
+ }
+};
+
+const clearPurchaseUpdatedDedupeHistoryIOS = (
+ history: PurchaseUpdatedDedupeHistoryIOS,
+) => {
+ history.ids.clear();
+ history.order.length = 0;
+ history.start = 0;
+};
+
+const resetPurchaseUpdatedDedupeHistoryIOS = () => {
+ clearPurchaseUpdatedDedupeHistoryIOS(purchaseUpdatedDedupeHistoryIOS);
+ purchaseUpdatedDedupeGenerationIOS += 1;
+};
+
+const configurePurchaseUpdatedListenerOptionsIOS = (
+ dedupeTransactionIOS: boolean,
+) => {
+ if (Platform.OS !== 'ios') return;
+
+ const nativeModule = getNativeModule() as NativePurchaseUpdatedOptionsModule;
+ const promise = nativeModule.setPurchaseUpdatedListenerOptions?.({
+ dedupeTransactionIOS,
+ });
+ void promise?.catch((error: unknown) => {
+ ExpoIapConsole.warn(
+ 'Failed to configure purchase updated listener options:',
+ error,
+ );
+ });
+};
+
/**
* TODO(v3.1.0): Remove legacy 'inapp' alias once downstream apps migrate to 'in-app'.
*/
@@ -159,16 +262,77 @@ const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] =>
export const purchaseUpdatedListener = (
listener: (event: Purchase) => void,
+ options?: PurchaseUpdatedListenerOptions | null,
) => {
+ const receiveDuplicateTransactionUpdatesIOS =
+ Platform.OS === 'ios' && options?.dedupeTransactionIOS === false;
+ const listenerDedupeHistoryIOS: PurchaseUpdatedDedupeHistoryIOS = {
+ ids: new Set(purchaseUpdatedDedupeHistoryIOS.ids),
+ order: purchaseUpdatedDedupeHistoryIOS.order.slice(
+ purchaseUpdatedDedupeHistoryIOS.start,
+ ),
+ start: 0,
+ };
+ let listenerDedupeGenerationIOS = purchaseUpdatedDedupeGenerationIOS;
+
const wrappedListener = (event: Purchase) => {
const normalized = normalizePurchasePlatform(event);
+ if (Platform.OS === 'ios') {
+ if (listenerDedupeGenerationIOS !== purchaseUpdatedDedupeGenerationIOS) {
+ clearPurchaseUpdatedDedupeHistoryIOS(listenerDedupeHistoryIOS);
+ listenerDedupeGenerationIOS = purchaseUpdatedDedupeGenerationIOS;
+ }
+ const transactionId = purchaseUpdatedTransactionIdIOS(normalized);
+ if (transactionId != null) {
+ const isDuplicateForListener =
+ listenerDedupeHistoryIOS.ids.has(transactionId);
+ rememberPurchaseUpdatedTransactionIOS(
+ transactionId,
+ listenerDedupeHistoryIOS,
+ );
+ rememberPurchaseUpdatedTransactionIOS(
+ transactionId,
+ purchaseUpdatedDedupeHistoryIOS,
+ );
+ if (!receiveDuplicateTransactionUpdatesIOS && isDuplicateForListener) {
+ return;
+ }
+ }
+ }
listener(normalized);
};
const emitterSubscription = emitter.addListener(
OpenIapEvent.PurchaseUpdated,
wrappedListener,
);
- return emitterSubscription;
+
+ if (!receiveDuplicateTransactionUpdatesIOS) {
+ return emitterSubscription;
+ }
+
+ nonDedupingPurchaseUpdatedListenerCountIOS += 1;
+ configurePurchaseUpdatedListenerOptionsIOS(false);
+ let removed = false;
+
+ return {
+ remove: () => {
+ if (removed) {
+ return;
+ }
+ removed = true;
+ try {
+ emitterSubscription?.remove?.();
+ } finally {
+ nonDedupingPurchaseUpdatedListenerCountIOS = Math.max(
+ 0,
+ nonDedupingPurchaseUpdatedListenerCountIOS - 1,
+ );
+ configurePurchaseUpdatedListenerOptionsIOS(
+ nonDedupingPurchaseUpdatedListenerCountIOS === 0,
+ );
+ }
+ },
+ };
};
export const purchaseErrorListener = (
@@ -402,16 +566,30 @@ export const subscriptionBillingIssueListener = (
*
* @see {@link https://www.openiap.dev/docs/apis/init-connection}
*/
-export const initConnection: MutationField<'initConnection'> = async (config) =>
- ExpoIapModule.initConnection(config ?? null);
+export const initConnection: MutationField<'initConnection'> = async (config) => {
+ const result = await ExpoIapModule.initConnection(config ?? null);
+ if (
+ result === true &&
+ Platform.OS === 'ios' &&
+ nonDedupingPurchaseUpdatedListenerCountIOS > 0
+ ) {
+ configurePurchaseUpdatedListenerOptionsIOS(false);
+ }
+ return result;
+};
/**
* Close the store connection and release resources.
*
* @see {@link https://www.openiap.dev/docs/apis/end-connection}
*/
-export const endConnection: MutationField<'endConnection'> = async () =>
- ExpoIapModule.endConnection();
+export const endConnection: MutationField<'endConnection'> = async () => {
+ const result = await ExpoIapModule.endConnection();
+ if (result === true && Platform.OS === 'ios') {
+ resetPurchaseUpdatedDedupeHistoryIOS();
+ }
+ return result;
+};
/**
* Retrieve products or subscriptions from the store by SKU.
@@ -1099,10 +1277,7 @@ export type {
UseWebhookEventsOptions,
UseWebhookEventsResult,
} from './useWebhookEvents';
-export {
- connectWebhookStream,
- parseWebhookEventData,
-} from './webhook-client';
+export {connectWebhookStream, parseWebhookEventData} from './webhook-client';
export type {
WebhookEventPayload,
WebhookEventStream,
diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts
index 7eb00860..fc6fef35 100644
--- a/libraries/expo-iap/src/types.ts
+++ b/libraries/expo-iap/src/types.ts
@@ -1292,6 +1292,15 @@ export interface PurchaseOptions {
export type PurchaseState = 'pending' | 'purchased' | 'unknown';
+export interface PurchaseUpdatedListenerOptions {
+ /**
+ * iOS only. Defaults to true. When false, listener callbacks also receive
+ * StoreKit replay events for a transaction ID that was already emitted during
+ * the current connection session. Android ignores this option.
+ */
+ dedupeTransactionIOS?: (boolean | null);
+}
+
export type PurchaseVerificationProvider = 'iapkit';
export interface Query {
@@ -1734,7 +1743,12 @@ export interface Subscription {
promotedProductIOS: string;
/** Fires when a purchase fails or is cancelled */
purchaseError: PurchaseError;
- /** Fires when a purchase completes successfully or a pending purchase resolves */
+ /**
+ * Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
+ */
purchaseUpdated: Purchase;
/**
* Fires when an active subscription enters a billing-issue state that needs user action
@@ -1758,6 +1772,9 @@ export interface Subscription {
}
+
+export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined;
+
export interface SubscriptionInfoIOS {
introductoryOffer?: (SubscriptionOfferIOS | null);
promotionalOffers?: (SubscriptionOfferIOS[] | null);
@@ -2218,7 +2235,7 @@ export type SubscriptionArgsMap = {
developerProvidedBillingAndroid: never;
promotedProductIOS: never;
purchaseError: never;
- purchaseUpdated: never;
+ purchaseUpdated: SubscriptionPurchaseUpdatedArgs;
subscriptionBillingIssue: never;
userChoiceBillingAndroid: never;
};
diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts
index 90853a68..4110eefb 100644
--- a/libraries/expo-iap/src/useIAP.ts
+++ b/libraries/expo-iap/src/useIAP.ts
@@ -43,6 +43,7 @@ import type {
Purchase,
MutationRequestPurchaseArgs,
PurchaseInput,
+ PurchaseUpdatedListenerOptions,
VerifyPurchaseProps,
VerifyPurchaseResult,
VerifyPurchaseWithProviderProps,
@@ -115,6 +116,12 @@ type UseIap = {
export interface UseIAPOptions {
onPurchaseSuccess?: (purchase: Purchase) => void;
onPurchaseError?: (error: PurchaseError) => void;
+ /**
+ * iOS only. When enabled, the purchase success listener also receives
+ * StoreKit replay events for a transaction ID already delivered during the
+ * current connection session. Defaults to false.
+ */
+ purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null;
/**
* Callback for general errors from hook methods like fetchProducts,
* getAvailablePurchases, getActiveSubscriptions, restorePurchases, etc.
@@ -628,6 +635,7 @@ export function useIAP(options?: UseIAPOptions): UseIap {
optionsRef.current.onPurchaseSuccess(purchase);
}
},
+ optionsRef.current?.purchaseUpdatedListenerOptions,
);
// Register purchase error listener EARLY. Ignore init-related errors until connected.
@@ -709,6 +717,7 @@ export function useIAP(options?: UseIAPOptions): UseIap {
optionsRef.current.onPurchaseSuccess(purchase);
}
},
+ optionsRef.current?.purchaseUpdatedListenerOptions,
);
}
diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
index 166d072d..e4416022 100644
--- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
+++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
@@ -17,6 +17,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
private var purchaseErrorToken: OpenIAP.Subscription?
private var promotedProductToken: OpenIAP.Subscription?
private var subscriptionBillingIssueToken: OpenIAP.Subscription?
+ private var purchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions()
// No local StoreKit caches; OpenIAP handles state internally
private var processedTransactionIds: Set = []
@@ -65,6 +66,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
case "endConnection":
endConnection(result: result)
+
+ case "setPurchaseUpdatedListenerOptions":
+ setPurchaseUpdatedListenerOptions(call: call, result: result)
case "fetchProducts":
// OpenIAP-compliant: accepts { skus: [String], type: 'inapp'|'subs'|'all' }
@@ -399,61 +403,78 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
// MARK: - OpenIAP Listeners
private func setupOpenIapListeners() {
- if purchaseUpdatedToken != nil || purchaseErrorToken != nil { return }
FlutterIapLog.debug("Setting up OpenIAP listeners")
- purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in
- Task { @MainActor in
- guard let self else { return }
- FlutterIapLog.debug("purchaseUpdatedListener fired for \(purchase.productId)")
- let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
- if let jsonString = FlutterIapHelper.jsonString(from: payload) {
- self.channel?.invokeMethod("purchase-updated", arguments: jsonString)
- }
- }
- }
-
- purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
- Task { @MainActor in
- guard let self else { return }
- FlutterIapLog.debug("purchaseErrorListener fired")
- let _ : [String: Any?] = [
- "code": error.code.rawValue,
- "message": error.message,
- "productId": error.productId
- ]
- let compacted = FlutterIapHelper.sanitizeDictionary([
- "code": error.code.rawValue,
- "message": error.message,
- "productId": error.productId
- ])
- if let jsonString = FlutterIapHelper.jsonString(from: compacted) {
- self.channel?.invokeMethod("purchase-error", arguments: jsonString)
+ attachPurchaseUpdatedListener()
+
+ if purchaseErrorToken == nil {
+ purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
+ Task { @MainActor in
+ guard let self else { return }
+ FlutterIapLog.debug("purchaseErrorListener fired")
+ let _ : [String: Any?] = [
+ "code": error.code.rawValue,
+ "message": error.message,
+ "productId": error.productId
+ ]
+ let compacted = FlutterIapHelper.sanitizeDictionary([
+ "code": error.code.rawValue,
+ "message": error.message,
+ "productId": error.productId
+ ])
+ if let jsonString = FlutterIapHelper.jsonString(from: compacted) {
+ self.channel?.invokeMethod("purchase-error", arguments: jsonString)
+ }
}
}
}
- promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
- Task { @MainActor in
- guard let self = self else { return }
- FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)")
- // Emit event that Dart expects: name 'iap-promoted-product' with String payload
- self.channel?.invokeMethod("iap-promoted-product", arguments: productId)
+ if promotedProductToken == nil {
+ promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
+ Task { @MainActor in
+ guard let self = self else { return }
+ FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)")
+ // Emit event that Dart expects: name 'iap-promoted-product' with String payload
+ self.channel?.invokeMethod("iap-promoted-product", arguments: productId)
+ }
}
}
- subscriptionBillingIssueToken = OpenIapModule.shared.subscriptionBillingIssueListener { [weak self] purchase in
- Task { @MainActor in
- guard let self else { return }
- FlutterIapLog.debug("subscriptionBillingIssueListener fired for \(purchase.productId)")
- let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
- if let jsonString = FlutterIapHelper.jsonString(from: payload) {
- self.channel?.invokeMethod("subscription-billing-issue", arguments: jsonString)
+ if subscriptionBillingIssueToken == nil {
+ subscriptionBillingIssueToken = OpenIapModule.shared.subscriptionBillingIssueListener { [weak self] purchase in
+ Task { @MainActor in
+ guard let self else { return }
+ FlutterIapLog.debug("subscriptionBillingIssueListener fired for \(purchase.productId)")
+ let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
+ if let jsonString = FlutterIapHelper.jsonString(from: payload) {
+ self.channel?.invokeMethod("subscription-billing-issue", arguments: jsonString)
+ }
}
}
}
}
+ private func setPurchaseUpdatedListenerOptions(call: FlutterMethodCall, result: @escaping FlutterResult) {
+ let args = call.arguments as? [String: Any]
+ purchaseUpdatedListenerOptions = PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS:
+ args?["dedupeTransactionIOS"] as? Bool
+ )
+ if let token = purchaseUpdatedToken {
+ OpenIapModule.shared.removeListener(token)
+ purchaseUpdatedToken = nil
+ }
+ attachPurchaseUpdatedListener()
+ result(nil)
+ }
+
+ private func attachPurchaseUpdatedListener() {
+ guard purchaseUpdatedToken == nil else { return }
+ purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] purchase in
+ self?.emitPurchaseUpdated(purchase, method: "purchase-updated")
+ }, options: purchaseUpdatedListenerOptions)
+ }
+
private func removeOpenIapListeners() {
if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) }
if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) }
@@ -464,6 +485,16 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
promotedProductToken = nil
subscriptionBillingIssueToken = nil
}
+
+ private func emitPurchaseUpdated(_ purchase: Purchase, method: String) {
+ Task { @MainActor in
+ FlutterIapLog.debug("\(method) fired for \(purchase.productId)")
+ let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
+ if let jsonString = FlutterIapHelper.jsonString(from: payload) {
+ self.channel?.invokeMethod(method, arguments: jsonString)
+ }
+ }
+ }
// All transaction event handling is routed via OpenIapModule listeners
diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
index 27d418a0..dbb8effa 100644
--- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
+++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
@@ -30,6 +31,37 @@ export 'errors.dart'
typedef PurchaseError = errors.PurchaseError;
typedef SubscriptionOfferAndroid = gentype.AndroidSubscriptionOfferInput;
+class _PurchaseUpdatedDedupeHistoryIOS {
+ static const int limit = 512;
+
+ final Set ids;
+ final Queue order;
+
+ _PurchaseUpdatedDedupeHistoryIOS()
+ : ids = {},
+ order = Queue();
+
+ _PurchaseUpdatedDedupeHistoryIOS.copy(
+ _PurchaseUpdatedDedupeHistoryIOS source,
+ ) : ids = Set.of(source.ids),
+ order = Queue.of(source.order);
+
+ bool contains(String id) => ids.contains(id);
+
+ void record(String id) {
+ if (!ids.add(id)) return;
+ order.addLast(id);
+ if (order.length > limit) {
+ ids.remove(order.removeFirst());
+ }
+ }
+
+ void clear() {
+ ids.clear();
+ order.clear();
+ }
+}
+
class FlutterInappPurchase with RequestPurchaseBuilderApi {
// Singleton instance
static FlutterInappPurchase? _instance;
@@ -99,10 +131,157 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
gentype.DeveloperProvidedBillingDetailsAndroid>.broadcast();
final StreamController _subscriptionBillingIssueListener =
StreamController.broadcast();
+ final _purchaseUpdatedDedupeHistoryIOS = _PurchaseUpdatedDedupeHistoryIOS();
+ int _purchaseUpdatedDedupeGenerationIOS = 0;
+ int _nonDedupingPurchaseUpdatedListenerCountIOS = 0;
/// Purchase updated event stream
- Stream get purchaseUpdatedListener =>
- _purchaseUpdatedListener.stream;
+ Stream get purchaseUpdatedListener => isIOS
+ ? _purchaseUpdatedListenerStreamIOS(dedupeTransactionIOS: true)
+ : _purchaseUpdatedListener.stream;
+
+ /// Purchase updated event stream with listener options.
+ ///
+ /// On iOS, set [PurchaseUpdatedListenerOptions.dedupeTransactionIOS]
+ /// to false to also receive StoreKit replay events for transaction IDs
+ /// already delivered during the current connection session. Android ignores
+ /// this flag. On iOS this configures shared native listener state for this
+ /// plugin instance; default streams still filter replayed IDs unless they
+ /// opt out with `dedupeTransactionIOS: false`.
+ Stream purchaseUpdatedListenerWithOptions(
+ gentype.PurchaseUpdatedListenerOptions? options,
+ ) {
+ if (!isIOS) {
+ return purchaseUpdatedListener;
+ }
+
+ final dedupeTransactionIOS = options?.dedupeTransactionIOS != false;
+ StreamSubscription? subscription;
+ late StreamController controller;
+ var retainedNonDedupingListener = false;
+ controller = StreamController.broadcast(
+ onListen: () async {
+ try {
+ if (dedupeTransactionIOS) {
+ await _setDedupingPurchaseUpdatedListenerOptionsIOS(options);
+ } else {
+ retainedNonDedupingListener = true;
+ await _retainNonDedupingPurchaseUpdatedListenerIOS();
+ }
+ if (!controller.hasListener) return;
+ subscription = _purchaseUpdatedListenerStreamIOS(
+ dedupeTransactionIOS: dedupeTransactionIOS,
+ ).listen(
+ controller.add,
+ onError: controller.addError,
+ onDone: controller.close,
+ );
+ } catch (error, stackTrace) {
+ controller.addError(error, stackTrace);
+ }
+ },
+ onCancel: () async {
+ final activeSubscription = subscription;
+ subscription = null;
+ try {
+ await activeSubscription?.cancel();
+ } finally {
+ if (retainedNonDedupingListener) {
+ retainedNonDedupingListener = false;
+ await _releaseNonDedupingPurchaseUpdatedListenerIOS();
+ }
+ }
+ },
+ );
+ return controller.stream;
+ }
+
+ Stream _purchaseUpdatedListenerStreamIOS({
+ required bool dedupeTransactionIOS,
+ }) {
+ return Stream.multi((controller) {
+ final listenerHistory = _PurchaseUpdatedDedupeHistoryIOS.copy(
+ _purchaseUpdatedDedupeHistoryIOS,
+ );
+ var listenerGeneration = _purchaseUpdatedDedupeGenerationIOS;
+ late StreamSubscription subscription;
+ subscription = _purchaseUpdatedListener.stream.listen(
+ (purchase) {
+ if (listenerGeneration != _purchaseUpdatedDedupeGenerationIOS) {
+ listenerHistory.clear();
+ listenerGeneration = _purchaseUpdatedDedupeGenerationIOS;
+ }
+
+ final transactionId = _purchaseUpdatedTransactionIdIOS(purchase);
+ if (transactionId != null) {
+ final isDuplicateForListener =
+ listenerHistory.contains(transactionId);
+ listenerHistory.record(transactionId);
+ _purchaseUpdatedDedupeHistoryIOS.record(transactionId);
+ if (dedupeTransactionIOS && isDuplicateForListener) {
+ return;
+ }
+ }
+
+ controller.add(purchase);
+ },
+ onError: controller.addError,
+ onDone: controller.close,
+ );
+ controller.onCancel = () => subscription.cancel();
+ }, isBroadcast: true);
+ }
+
+ String? _purchaseUpdatedTransactionIdIOS(gentype.Purchase purchase) {
+ final id = purchase.id;
+ return id.isEmpty ? null : id;
+ }
+
+ void _resetPurchaseUpdatedDedupeHistoryIOS() {
+ _purchaseUpdatedDedupeHistoryIOS.clear();
+ _purchaseUpdatedDedupeGenerationIOS += 1;
+ }
+
+ Future _setPurchaseUpdatedListenerOptions(
+ gentype.PurchaseUpdatedListenerOptions? options,
+ ) =>
+ _configurePurchaseListener(() async {
+ await _setPurchaseListener();
+ await _channel.invokeMethod(
+ 'setPurchaseUpdatedListenerOptions',
+ options?.toJson(),
+ );
+ });
+
+ Future _setDedupingPurchaseUpdatedListenerOptionsIOS(
+ gentype.PurchaseUpdatedListenerOptions? options,
+ ) async {
+ if (_nonDedupingPurchaseUpdatedListenerCountIOS > 0) return;
+ await _setPurchaseUpdatedListenerOptions(options);
+ }
+
+ Future _retainNonDedupingPurchaseUpdatedListenerIOS() async {
+ _nonDedupingPurchaseUpdatedListenerCountIOS += 1;
+ if (_nonDedupingPurchaseUpdatedListenerCountIOS == 1) {
+ await _setPurchaseUpdatedListenerOptions(
+ const gentype.PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: false,
+ ),
+ );
+ }
+ }
+
+ Future _releaseNonDedupingPurchaseUpdatedListenerIOS() async {
+ if (_nonDedupingPurchaseUpdatedListenerCountIOS == 0) return;
+ _nonDedupingPurchaseUpdatedListenerCountIOS -= 1;
+ if (_nonDedupingPurchaseUpdatedListenerCountIOS == 0) {
+ await _setPurchaseUpdatedListenerOptions(
+ const gentype.PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: true,
+ ),
+ );
+ }
+ }
/// Purchase error event stream
Stream get purchaseErrorListener =>
@@ -128,6 +307,16 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
_subscriptionBillingIssueListener.stream;
bool _isInitialized = false;
+ Future _purchaseListenerConfiguration = Future.value();
+
+ Future _configurePurchaseListener(
+ Future Function() configure,
+ ) {
+ final previous = _purchaseListenerConfiguration;
+ final current = previous.catchError((Object _) {}).then((_) => configure());
+ _purchaseListenerConfiguration = current.catchError((Object _) {});
+ return current;
+ }
Future _setPurchaseListener() async {
_purchaseController ??= StreamController.broadcast();
@@ -138,28 +327,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
_channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'purchase-updated':
- try {
- Map result =
- jsonDecode(call.arguments as String) as Map;
-
- // Convert directly to Purchase without intermediate PurchasedItem
- final purchase = convertToPurchase(
- result,
- originalJson: result,
- platformIsAndroid: _platform.isAndroid,
- platformIsIOS: _platform.isIOS || _platform.isMacOS,
- acknowledgedAndroidPurchaseTokens:
- _acknowledgedAndroidPurchaseTokens,
- );
-
- _purchaseController!.add(purchase);
- _purchaseUpdatedListener.add(purchase);
- } catch (e, stackTrace) {
- debugPrint(
- '[flutter_inapp_purchase] ERROR in purchase-updated: $e',
- );
- debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace');
- }
+ _handlePurchaseUpdatedCall(
+ call,
+ _purchaseUpdatedListener,
+ publishToLegacyStream: true,
+ );
break;
case 'purchase-error':
debugPrint(
@@ -246,6 +418,35 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
});
}
+ void _handlePurchaseUpdatedCall(
+ MethodCall call,
+ StreamController controller, {
+ bool publishToLegacyStream = false,
+ }) {
+ try {
+ final result =
+ jsonDecode(call.arguments as String) as Map;
+
+ final purchase = convertToPurchase(
+ result,
+ originalJson: result,
+ platformIsAndroid: _platform.isAndroid,
+ platformIsIOS: _platform.isIOS || _platform.isMacOS,
+ acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens,
+ );
+
+ if (publishToLegacyStream) {
+ _purchaseController!.add(purchase);
+ }
+ controller.add(purchase);
+ } catch (e, stackTrace) {
+ debugPrint(
+ '[flutter_inapp_purchase] ERROR in ${call.method}: $e',
+ );
+ debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace');
+ }
+ }
+
/// Initialize the store connection. Must be called before any other IAP API.
///
/// Parameters:
@@ -271,7 +472,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
}
try {
- await _setPurchaseListener();
+ await _configurePurchaseListener(_setPurchaseListener);
// Build config map for alternative billing and billing program
Map? config;
@@ -317,6 +518,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
await _channel.invokeMethod('endConnection');
_isInitialized = false;
+ if (isIOS) {
+ _resetPurchaseUpdatedDedupeHistoryIOS();
+ }
return true;
} on PlatformException catch (error) {
throw _purchaseErrorFromPlatformException(
@@ -2619,7 +2823,29 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
},
purchaseError: () async =>
await purchaseErrorListener.first as gentype.PurchaseError,
- purchaseUpdated: () async => await purchaseUpdatedListener.first,
+ purchaseUpdated: ({bool? dedupeTransactionIOS}) async {
+ final options = gentype.PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: dedupeTransactionIOS,
+ );
+ if (isIOS) {
+ final shouldDedupe = dedupeTransactionIOS != false;
+ if (shouldDedupe) {
+ await _setDedupingPurchaseUpdatedListenerOptionsIOS(options);
+ } else {
+ await _retainNonDedupingPurchaseUpdatedListenerIOS();
+ }
+ try {
+ return await _purchaseUpdatedListenerStreamIOS(
+ dedupeTransactionIOS: shouldDedupe,
+ ).first;
+ } finally {
+ if (!shouldDedupe) {
+ await _releaseNonDedupingPurchaseUpdatedListenerIOS();
+ }
+ }
+ }
+ return purchaseUpdatedListener.first;
+ },
subscriptionBillingIssue: () async =>
await subscriptionBillingIssueListener.first,
userChoiceBillingAndroid: () async =>
diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart
index 93997a8c..2558620f 100644
--- a/libraries/flutter_inapp_purchase/lib/types.dart
+++ b/libraries/flutter_inapp_purchase/lib/types.dart
@@ -4347,6 +4347,29 @@ class PurchaseOptions {
}
}
+class PurchaseUpdatedListenerOptions {
+ const PurchaseUpdatedListenerOptions({
+ this.dedupeTransactionIOS,
+ });
+
+ /// iOS only. Defaults to true. When false, listener callbacks also receive
+ /// StoreKit replay events for a transaction ID that was already emitted during
+ /// the current connection session. Android ignores this option.
+ final bool? dedupeTransactionIOS;
+
+ factory PurchaseUpdatedListenerOptions.fromJson(Map json) {
+ return PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: json['dedupeTransactionIOS'] as bool?,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'dedupeTransactionIOS': dedupeTransactionIOS,
+ };
+ }
+}
+
class RequestPurchaseAndroidProps {
const RequestPurchaseAndroidProps({
this.developerBillingOption,
@@ -5452,7 +5475,12 @@ abstract class SubscriptionResolver {
/// Fires when a purchase fails or is cancelled
Future purchaseError();
/// Fires when a purchase completes successfully or a pending purchase resolves
- Future purchaseUpdated();
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ Future purchaseUpdated({
+ bool? dedupeTransactionIOS,
+ });
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
///
@@ -5672,7 +5700,9 @@ class QueryHandlers {
typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function();
typedef SubscriptionPromotedProductIOSHandler = Future Function();
typedef SubscriptionPurchaseErrorHandler = Future Function();
-typedef SubscriptionPurchaseUpdatedHandler = Future Function();
+typedef SubscriptionPurchaseUpdatedHandler = Future Function({
+ bool? dedupeTransactionIOS,
+});
typedef SubscriptionSubscriptionBillingIssueHandler = Future Function();
typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function();
diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
index 2195a774..075e915f 100644
--- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
+++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart
@@ -1224,6 +1224,207 @@ void main() {
expect(listenerPurchase.productId, 'iap.premium');
});
+ test(
+ 'default purchase listener filters replays while non-deduping listener receives them',
+ () async {
+ final calls = [];
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(channel, (MethodCall call) async {
+ calls.add(call);
+ if (call.method == 'initConnection') {
+ return true;
+ }
+ return null;
+ });
+
+ final iap = FlutterInappPurchase.private(
+ FakePlatform(operatingSystem: 'ios'),
+ );
+ final defaultPurchases = [];
+ final nonDedupingPurchases = [];
+
+ await iap.initConnection();
+ final defaultSub = iap.purchaseUpdatedListener.listen(
+ defaultPurchases.add,
+ );
+ final nonDedupingSub = iap
+ .purchaseUpdatedListenerWithOptions(
+ const types.PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: false,
+ ),
+ )
+ .listen(nonDedupingPurchases.add);
+ await Future.delayed(Duration.zero);
+ await Future.delayed(Duration.zero);
+
+ final purchasePayload = {
+ 'platform': 'ios',
+ 'store': 'apple',
+ 'productId': 'iap.premium',
+ 'transactionId': 'txn-dedupe-replay',
+ 'purchaseState': 'PURCHASED',
+ 'transactionReceipt': 'receipt-data',
+ 'transactionDate': 1700000000000,
+ };
+
+ for (var i = 0; i < 2; i += 1) {
+ await TestDefaultBinaryMessengerBinding
+ .instance.defaultBinaryMessenger
+ .handlePlatformMessage(
+ channel.name,
+ codec.encodeMethodCall(
+ MethodCall('purchase-updated', jsonEncode(purchasePayload)),
+ ),
+ (_) {},
+ );
+ }
+ await Future.delayed(Duration.zero);
+
+ expect(defaultPurchases, hasLength(1));
+ expect(nonDedupingPurchases, hasLength(2));
+
+ await defaultSub.cancel();
+ await nonDedupingSub.cancel();
+ await Future.delayed(Duration.zero);
+ await Future.delayed(Duration.zero);
+
+ final optionCalls = calls
+ .where((call) => call.method == 'setPurchaseUpdatedListenerOptions')
+ .toList();
+ expect(optionCalls.map((call) => call.arguments),
+
Duplicate Transaction Replays on iOS
+
+ StoreKit can replay the same unfinished transaction through more than
+ one native path during a single connection session. By default, OpenIAP
+ delivers one purchaseUpdated event per iOS transaction ID
+ to purchase-success listeners, while still keeping distinct transactions
+ separate. This prevents entitlement delivery from running twice for the
+ same purchase.
+
+
+ For diagnostics, register the purchase update listener with{' '}
+ dedupeTransactionIOS: false. The flag belongs to the
+ purchase update listener only; purchase error listeners do not receive
+ successful StoreKit transactions. Android ignores this iOS-only option.
+
Registers a listener for successful purchase events.
+
Opt In to iOS StoreKit Replays
+
+ {{
+ typescript: (
+ {`const subscription = purchaseUpdatedListener(
+ (purchase) => {
+ console.log('StoreKit replay or first delivery:', purchase.id);
+ },
+ { dedupeTransactionIOS: false }
+);`}
+ ),
+ swift: (
+ {`let subscription = OpenIapModule.shared.purchaseUpdatedListener(
+ { purchase in
+ print("StoreKit replay or first delivery: \\(purchase.id)")
+ },
+ options: PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: false
+ )
+)`}
+ ),
+ kmp: (
+ {`val updates = kmpIAP.purchaseUpdatedListener(
+ PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS = false
+ )
+)`}
+ ),
+ dart: (
+ {`final updates = FlutterInappPurchase.instance
+ .purchaseUpdatedListenerWithOptions(
+ const PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: false,
+ ),
+);`}
+ ),
+ csharp: (
+ {`var updates = Iap.Instance.PurchaseUpdatedWithOptions(
+ new PurchaseUpdatedListenerOptions
+ {
+ DedupeTransactionIOS = false,
+ });`}
+ ),
+ gdscript: (
+ {`var options = Types.PurchaseUpdatedListenerOptions.new()
+options.dedupe_transaction_ios = false
+iap.set_purchase_updated_listener_options(options)`}
+ ),
+ }}
+
+
{{
typescript: (
diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx
index cc88b34e..24888e39 100644
--- a/packages/docs/src/pages/docs/index.tsx
+++ b/packages/docs/src/pages/docs/index.tsx
@@ -20,6 +20,7 @@ import TypesStorefront from './types/storefront';
import TypesPurchase from './types/purchase';
import TypesActiveSubscription from './types/active-subscription';
import TypesProductRequest from './types/product-request';
+import TypesPurchaseUpdatedListenerOptions from './types/purchase-updated-listener-options';
import TypesRequestPurchaseProps from './types/request-purchase-props';
import TypesAlternativeBillingTypes from './types/alternative-billing-types';
import TypesBillingPrograms from './types/billing-programs';
@@ -256,6 +257,10 @@ function Docs() {
to: '/docs/types/request-purchase-props',
label: 'RequestPurchaseProps',
},
+ {
+ to: '/docs/types/purchase-updated-listener-options',
+ label: 'PurchaseUpdatedListenerOptions',
+ },
{
to: '/docs/types/discount-offer',
label: 'DiscountOffer',
@@ -819,6 +824,10 @@ function Docs() {
path="types/request-purchase-props"
element={}
/>
+ }
+ />
}
diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx
index c18bff1d..276c8535 100644
--- a/packages/docs/src/pages/docs/types/index.tsx
+++ b/packages/docs/src/pages/docs/types/index.tsx
@@ -18,6 +18,8 @@ const LEGACY_ANCHOR_REDIRECTS: Record = {
'active-subscription': '/docs/types/active-subscription',
'product-request': '/docs/types/product-request',
'request-purchase-props': '/docs/types/request-purchase-props',
+ 'purchase-updated-listener-options':
+ '/docs/types/purchase-updated-listener-options',
'discount-offer': '/docs/types/discount-offer',
'subscription-offer': '/docs/types/subscription-offer',
'verify-purchase': '/docs/types/verify-purchase',
@@ -121,6 +123,11 @@ const COMMON_TYPES: TypeRow[] = [
name: 'RequestPurchaseProps',
description: 'Discriminated union for one-time purchases or subscriptions.',
},
+ {
+ to: '/docs/types/purchase-updated-listener-options',
+ name: 'PurchaseUpdatedListenerOptions',
+ description: 'Options for purchase update listener replay behavior.',
+ },
{
to: '/docs/types/discount-offer',
name: 'DiscountOffer',
diff --git a/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx
new file mode 100644
index 00000000..f2f6662f
--- /dev/null
+++ b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx
@@ -0,0 +1,130 @@
+import { Link } from 'react-router-dom';
+import AnchorLink from '../../../components/AnchorLink';
+import CodeBlock from '../../../components/CodeBlock';
+import LanguageTabs from '../../../components/LanguageTabs';
+import SEO from '../../../components/SEO';
+import { useScrollToHash } from '../../../hooks/useScrollToHash';
+
+/**
+ * Renders the docs page for purchase update listener options.
+ * @returns The PurchaseUpdatedListenerOptions documentation page.
+ */
+function PurchaseUpdatedListenerOptions() {
+ useScrollToHash();
+
+ return (
+
+
+
PurchaseUpdatedListenerOptions
+
+
+
+ PurchaseUpdatedListenerOptions
+
+
+ Options passed when registering{' '}
+
+ purchaseUpdatedListener
+
+ . The current option is iOS-only and controls whether a listener
+ receives StoreKit replay events for transaction IDs already delivered
+ during the current connection session.
+
+
+
+ Fields
+
+
+
+
+
Name
+
Type
+
Summary
+
+
+
+
+
+ dedupeTransactionIOS
+
+
+ boolean?
+
+
+ iOS only. Defaults to true. When false, the
+ listener also receives StoreKit replay events for a transaction
+ ID already emitted during the current connection session.
+ Android ignores this option.
+
+
+
+
+
+
+ Default Behavior
+
+
+ The default is designed for entitlement safety: purchase success
+ handlers run once per iOS transaction ID during one connection
+ session. Set the flag to false only when you need to
+ inspect native StoreKit replay behavior or build your own duplicate
+ handling.
+
+ Publishes OpenIAP Spec 2.0.2 with{' '}
+ PurchaseUpdatedListenerOptions and an iOS-only{' '}
+ dedupeTransactionIOS flag. StoreKit can replay the same
+ unfinished transaction through request and transaction-update paths
+ during a single connection session. OpenIAP now gives developers an
+ explicit choice: keep dedupeTransactionIOS omitted or{' '}
+ true to receive one purchase success event per iOS
+ transaction ID, or set dedupeTransactionIOS: false when
+ you want StoreKit replay events for diagnostics or custom duplicate
+ handling. Android ignores this iOS-only option. The planned patch
+ rollout carries this behavior through openiap-apple, openiap-google,
+ and all six framework SDKs. Track the fix in{' '}
+
+ issue #152
+ {' '}
+ and{' '}
+
+ PR #153
+
+ .
+
+
+
+
+ Listener-level opt-in — React Native and Expo
+ accept dedupeTransactionIOS on{' '}
+ purchaseUpdatedListener; Flutter, KMP, MAUI, and
+ Godot expose equivalent stream or signal-level options without
+ changing default purchase success handling.
+
+
+ Native debugging preserved — openiap-apple no
+ longer drops duplicate StoreKit updates before framework bridges
+ can observe them. Default listeners dedupe duplicate transaction
+ IDs, while listeners with dedupeTransactionIOS: false{' '}
+ receive the replay.
+
+
+ Platform scope — this option is only meaningful
+ on iOS because Android does not have the same StoreKit replay
+ path; Android SDKs accept the generated field for parity and
+ ignore it at runtime.
+
+
+ Docs and type sync — the generated GQL types now
+ include PurchaseUpdatedListenerOptions across Swift,
+ Kotlin, TypeScript, Dart, GDScript, and C#.
+
+
+ Usage guide — see{' '}
+
+ purchaseUpdatedListener
+ {' '}
+ for the default behavior and opt-in examples.
+
+
+
+
+
Planned Package Releases
+
+
OpenIAP Spec 2.0.2
+
openiap-apple 2.1.9
+
openiap-google 2.1.5
+
react-native-iap 15.2.4
+
expo-iap 4.2.8
+
flutter_inapp_purchase 9.2.8
+
godot-iap 2.2.10
+
kmp-iap 2.2.8
+
OpenIap.Maui 1.0.4
+
+
+
+ ),
+ },
+
// May 13, 2026 — godot-iap 2.2.9 macOS Gatekeeper packaging fix
{
id: 'godot-iap-2-2-9-macos-gatekeeper-packaging',
diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
index edc23e07..58192905 100644
--- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
+++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt
@@ -4294,6 +4294,27 @@ public data class PurchaseOptions(
)
}
+public data class PurchaseUpdatedListenerOptions(
+ /**
+ * iOS only. Defaults to true. When false, listener callbacks also receive
+ * StoreKit replay events for a transaction ID that was already emitted during
+ * the current connection session. Android ignores this option.
+ */
+ val dedupeTransactionIOS: Boolean? = null
+) {
+ companion object {
+ fun fromJson(json: Map): PurchaseUpdatedListenerOptions {
+ return PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "dedupeTransactionIOS" to dedupeTransactionIOS,
+ )
+}
+
public data class RequestPurchaseAndroidProps(
/**
* Developer billing option parameters for external payments flow (8.3.0+).
@@ -5441,8 +5462,11 @@ public interface SubscriptionResolver {
suspend fun purchaseError(): PurchaseError
/**
* Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
*/
- suspend fun purchaseUpdated(): Purchase
+ suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions? = null): Purchase
/**
* Fires when an active subscription enters a billing-issue state that needs user action
* (payment method failed, card expired, etc.). Cross-platform unification:
@@ -5577,7 +5601,7 @@ public data class QueryHandlers(
public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () -> DeveloperProvidedBillingDetailsAndroid
public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String
public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError
-public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase
+public typealias SubscriptionPurchaseUpdatedHandler = suspend (options: PurchaseUpdatedListenerOptions?) -> Purchase
public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase
public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails
diff --git a/packages/gql/src/event.graphql b/packages/gql/src/event.graphql
index 3687e7c1..e0d12f89 100644
--- a/packages/gql/src/event.graphql
+++ b/packages/gql/src/event.graphql
@@ -3,8 +3,11 @@
extend type Subscription {
"""
Fires when a purchase completes successfully or a pending purchase resolves
+ Options can opt iOS listeners into duplicate StoreKit transaction replays
+ for diagnostics; default listeners receive one event per transaction ID
+ during a single connection session.
"""
- purchaseUpdated: Purchase!
+ purchaseUpdated(options: PurchaseUpdatedListenerOptions): Purchase!
"""
Fires when a purchase fails or is cancelled
"""
diff --git a/packages/gql/src/generated/Types.cs b/packages/gql/src/generated/Types.cs
index 91e82bf4..e89111dd 100644
--- a/packages/gql/src/generated/Types.cs
+++ b/packages/gql/src/generated/Types.cs
@@ -3762,6 +3762,15 @@ public sealed record PurchaseOptions
public bool? IncludeSuspendedAndroid { get; init; }
}
+public sealed record PurchaseUpdatedListenerOptions
+{
+ /// iOS only. Defaults to true. When false, listener callbacks also receive
+ /// StoreKit replay events for a transaction ID that was already emitted during
+ /// the current connection session. Android ignores this option.
+ [JsonPropertyName("dedupeTransactionIOS")]
+ public bool? DedupeTransactionIOS { get; init; }
+}
+
public sealed record RequestPurchaseAndroidProps
{
/// List of product SKUs
@@ -4347,7 +4356,10 @@ public interface SubscriptionResolver
Task PurchaseErrorAsync();
/// Fires when a purchase completes successfully or a pending purchase resolves
- Task PurchaseUpdatedAsync();
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ Task PurchaseUpdatedAsync(PurchaseUpdatedListenerOptions? options = null);
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index b393cf86..7e1e6572 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -4413,6 +4413,27 @@ public data class PurchaseOptions(
)
}
+public data class PurchaseUpdatedListenerOptions(
+ /**
+ * iOS only. Defaults to true. When false, listener callbacks also receive
+ * StoreKit replay events for a transaction ID that was already emitted during
+ * the current connection session. Android ignores this option.
+ */
+ val dedupeTransactionIOS: Boolean? = null
+) {
+ companion object {
+ fun fromJson(json: Map): PurchaseUpdatedListenerOptions {
+ return PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS = json["dedupeTransactionIOS"] as? Boolean,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "dedupeTransactionIOS" to dedupeTransactionIOS,
+ )
+}
+
public data class RequestPurchaseAndroidProps(
/**
* Developer billing option parameters for external payments flow (8.3.0+).
@@ -5560,8 +5581,11 @@ public interface SubscriptionResolver {
suspend fun purchaseError(): PurchaseError
/**
* Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
*/
- suspend fun purchaseUpdated(): Purchase
+ suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions? = null): Purchase
/**
* Fires when an active subscription enters a billing-issue state that needs user action
* (payment method failed, card expired, etc.). Cross-platform unification:
@@ -5696,7 +5720,7 @@ public data class QueryHandlers(
public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () -> DeveloperProvidedBillingDetailsAndroid
public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String
public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError
-public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase
+public typealias SubscriptionPurchaseUpdatedHandler = suspend (options: PurchaseUpdatedListenerOptions?) -> Purchase
public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase
public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index 4b6a09da..8cde48d8 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/Types.swift
@@ -1687,6 +1687,19 @@ public struct PurchaseOptions: Codable {
}
}
+public struct PurchaseUpdatedListenerOptions: Codable {
+ /// iOS only. Defaults to true. When false, listener callbacks also receive
+ /// StoreKit replay events for a transaction ID that was already emitted during
+ /// the current connection session. Android ignores this option.
+ public var dedupeTransactionIOS: Bool?
+
+ public init(
+ dedupeTransactionIOS: Bool? = nil
+ ) {
+ self.dedupeTransactionIOS = dedupeTransactionIOS
+ }
+}
+
public struct RequestPurchaseAndroidProps: Codable {
/// Developer billing option parameters for external payments flow (8.3.0+).
/// When provided, the purchase flow will show a side-by-side choice between
@@ -2695,7 +2708,10 @@ public protocol SubscriptionResolver {
/// Fires when a purchase fails or is cancelled
func purchaseError() async throws -> PurchaseError
/// Fires when a purchase completes successfully or a pending purchase resolves
- func purchaseUpdated() async throws -> Purchase
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
///
@@ -2928,7 +2944,7 @@ public struct QueryHandlers {
public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = () async throws -> DeveloperProvidedBillingDetailsAndroid
public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String
public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError
-public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase
+public typealias SubscriptionPurchaseUpdatedHandler = (_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase
public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase
public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails
diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index 93997a8c..2558620f 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -4347,6 +4347,29 @@ class PurchaseOptions {
}
}
+class PurchaseUpdatedListenerOptions {
+ const PurchaseUpdatedListenerOptions({
+ this.dedupeTransactionIOS,
+ });
+
+ /// iOS only. Defaults to true. When false, listener callbacks also receive
+ /// StoreKit replay events for a transaction ID that was already emitted during
+ /// the current connection session. Android ignores this option.
+ final bool? dedupeTransactionIOS;
+
+ factory PurchaseUpdatedListenerOptions.fromJson(Map json) {
+ return PurchaseUpdatedListenerOptions(
+ dedupeTransactionIOS: json['dedupeTransactionIOS'] as bool?,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'dedupeTransactionIOS': dedupeTransactionIOS,
+ };
+ }
+}
+
class RequestPurchaseAndroidProps {
const RequestPurchaseAndroidProps({
this.developerBillingOption,
@@ -5452,7 +5475,12 @@ abstract class SubscriptionResolver {
/// Fires when a purchase fails or is cancelled
Future purchaseError();
/// Fires when a purchase completes successfully or a pending purchase resolves
- Future purchaseUpdated();
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ Future purchaseUpdated({
+ bool? dedupeTransactionIOS,
+ });
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
///
@@ -5672,7 +5700,9 @@ class QueryHandlers {
typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function();
typedef SubscriptionPromotedProductIOSHandler = Future Function();
typedef SubscriptionPurchaseErrorHandler = Future Function();
-typedef SubscriptionPurchaseUpdatedHandler = Future Function();
+typedef SubscriptionPurchaseUpdatedHandler = Future Function({
+ bool? dedupeTransactionIOS,
+});
typedef SubscriptionSubscriptionBillingIssueHandler = Future Function();
typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function();
diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd
index 29d37286..13bb03b9 100644
--- a/packages/gql/src/generated/types.gd
+++ b/packages/gql/src/generated/types.gd
@@ -3850,6 +3850,22 @@ class PurchaseOptions:
dict["includeSuspendedAndroid"] = include_suspended_android
return dict
+class PurchaseUpdatedListenerOptions:
+ ## iOS only. Defaults to true. When false, listener callbacks also receive
+ var dedupe_transaction_ios: Variant = null
+
+ static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions:
+ var obj = PurchaseUpdatedListenerOptions.new()
+ if data.has("dedupeTransactionIOS") and data["dedupeTransactionIOS"] != null:
+ obj.dedupe_transaction_ios = data["dedupeTransactionIOS"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ if dedupe_transaction_ios != null:
+ dict["dedupeTransactionIOS"] = dedupe_transaction_ios
+ return dict
+
class RequestPurchaseAndroidProps:
## List of product SKUs
var skus: Array[String] = []
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index 7eb00860..fc6fef35 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -1292,6 +1292,15 @@ export interface PurchaseOptions {
export type PurchaseState = 'pending' | 'purchased' | 'unknown';
+export interface PurchaseUpdatedListenerOptions {
+ /**
+ * iOS only. Defaults to true. When false, listener callbacks also receive
+ * StoreKit replay events for a transaction ID that was already emitted during
+ * the current connection session. Android ignores this option.
+ */
+ dedupeTransactionIOS?: (boolean | null);
+}
+
export type PurchaseVerificationProvider = 'iapkit';
export interface Query {
@@ -1734,7 +1743,12 @@ export interface Subscription {
promotedProductIOS: string;
/** Fires when a purchase fails or is cancelled */
purchaseError: PurchaseError;
- /** Fires when a purchase completes successfully or a pending purchase resolves */
+ /**
+ * Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
+ */
purchaseUpdated: Purchase;
/**
* Fires when an active subscription enters a billing-issue state that needs user action
@@ -1758,6 +1772,9 @@ export interface Subscription {
}
+
+export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined;
+
export interface SubscriptionInfoIOS {
introductoryOffer?: (SubscriptionOfferIOS | null);
promotionalOffers?: (SubscriptionOfferIOS[] | null);
@@ -2218,7 +2235,7 @@ export type SubscriptionArgsMap = {
developerProvidedBillingAndroid: never;
promotedProductIOS: never;
purchaseError: never;
- purchaseUpdated: never;
+ purchaseUpdated: SubscriptionPurchaseUpdatedArgs;
subscriptionBillingIssue: never;
userChoiceBillingAndroid: never;
};
diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql
index 60b2175d..1bc2f085 100644
--- a/packages/gql/src/type.graphql
+++ b/packages/gql/src/type.graphql
@@ -151,6 +151,16 @@ input PurchaseOptions {
includeSuspendedAndroid: Boolean
}
+# Listener registration options for purchase update events
+input PurchaseUpdatedListenerOptions {
+ """
+ iOS only. Defaults to true. When false, listener callbacks also receive
+ StoreKit replay events for a transaction ID that was already emitted during
+ the current connection session. Android ignores this option.
+ """
+ dedupeTransactionIOS: Boolean
+}
+
# Parameters for requestPurchase
input RequestPurchaseProps {
"""