Support Observer Mode in AdaptyUI
Hello,
We're currently using Adapty Flutter SDK v3.4.1 and encountering a critical issue on iOS.
No matter what we try, we consistently get error 1003 when initiating an in-app purchase using AdaptyUI with a remote paywall and a custom observer.
- Flutter version: 3.29.2
- Xcode version: 16.3
- iOS only (works fine on Android)
- Error: 1003 during purchase start, precisely it shows the Paywall but when we click on a product to start the IAP process we get that error;
What we've tested:
- If we avoid using the remote paywall builder and use Adapty() directly (not AdaptyUI), everything works fine.
- Our previous app version, using the same paywall and Adapty 3.2.5, works without issues.
- This confirms our configuration is correct and the issue seems to be introduced in a recent Adapty version.
Could you please help us investigate this?
Thanks in advance!
Hi @emanueltesoriello! Am I understand right, that you are trying to use AdaptyUI paywall while activating the SDK in observer mode?
Hello @x401om yes precisely. This was working till Adapty 3.2.5 on iOS.
@emanueltesoriello If you’re using the SDK in observer mode, you’re expected to handle the purchasing process yourself. That’s why, in the iOS SDK, we allow you to register an AdaptyObserverModeResolver to intercept the user’s intention to make a purchase inside our paywalls and handle it manually. However, this isn’t supported in the cross-platform SDKs yet. By the way, why not use full mode instead of observer mode?
@x401om I'm not fully understanding this. TO be precise, now we activate Adapty:
Future<void> _activateAdaptyIfNeeded() async {
try {
// Adapty docs suggest not using this in production to avoid any unwanted effects
var isActivated = false;
if (kDebugMode) {
isActivated = await Adapty().isActivated();
} else {
isActivated = false;
}
if (!isActivated) {
await Adapty().activate(
configuration: AdaptyConfiguration(apiKey: Config.adaptyKey)
..withObserverMode(true)
..withActivateUI(true));
} else {
Adapty().setupAfterHotRestart();
}
} catch (e) {
await SentryService.addBreadcrumb("Failed to initialise Adapty");
await SentryService.captureException(e);
}
}
And we use it like this where we need to show a paywall:
Future<void> showPaywall({
required BuildContext context,
required VoidCallback onSuccess,
required VoidCallback onCancelled,
required Function(String) onError,
required String placementId,
String? locale,
bool showTrial = true,
}) async {
// Create an instance of your custom observer.
final observer = CustomAdaptyObserver(
onSuccess: onSuccess,
onCancelled: onCancelled,
onError: onError,
);
final PreferencesRepository preferences = PreferencesRepositoryImpl.prefsProvider;
preferences.setIsInPendingPaywall(true);
BackgroundService.cancelServices();
BackgroundService.cancelInstantOneOffTask();
try {
// Set the observer to handle events.
var adaptyUI = AdaptyUI();
adaptyUI.setObserver(observer);
// Retrieve the paywall from Adapty.
final paywall = await Adapty().getPaywall(placementId: placementId);
// Create and present the paywall view.
final adaptyUIView = await adaptyUI.createPaywallView(paywall: paywall);
await adaptyUIView.present();
preferences.setIsInPendingPaywall(false);
BackgroundService.registerPeriodicTask();
} catch (error) {
preferences.setIsInPendingPaywall(false);
BackgroundService.registerPeriodicTask();
onError(error.toString());
}
}
And this is the CustomObserver:
class CustomAdaptyObserver extends AdaptyUIObserver {
final VoidCallback onSuccess;
final VoidCallback onCancelled;
final Function(String) onError;
CustomAdaptyObserver({
required this.onSuccess,
required this.onCancelled,
required this.onError,
});
@override
void paywallViewDidFinishPurchase(
AdaptyUIView view,
AdaptyPaywallProduct product,
AdaptyPurchaseResult purchaseResult,
) async {
// Handle a successful purchase or pending purchase as success.
if (purchaseResult is AdaptyPurchaseResultSuccess || purchaseResult is AdaptyPurchaseResultPending) {
await view.dismiss();
onSuccess();
final PreferencesRepository preferences = PreferencesRepositoryImpl.prefsProvider;
preferences.setIsInPendingPaywall(false);
} else if (purchaseResult is AdaptyPurchaseResultUserCancelled) {
await view.dismiss();
onCancelled();
} else {
await view.dismiss();
}
}
@override
void paywallViewDidFinishRestore(AdaptyUIView view, AdaptyProfile profile) async {
// Check if at least one access level is active. Adjust this logic based on your needs.
final levels = profile.accessLevels.values.toList();
final isActive = levels.isNotEmpty && levels.first.isActive;
if (!isActive) {
await view.dismiss();
onError("You don't have an active valid plan.");
} else {
await view.dismiss();
onSuccess();
final PreferencesRepository preferences = PreferencesRepositoryImpl.prefsProvider;
preferences.setIsInPendingPaywall(false);
}
}
@override
void paywallViewDidFailLoadingProducts(AdaptyUIView view, AdaptyError error) async {
await view.dismiss();
onError("Failed loading products: ${error.message}");
}
@override
void paywallViewDidFailPurchase(AdaptyUIView view, AdaptyPaywallProduct product, AdaptyError error) async {
await view.dismiss();
onError("Failed purchase: ${error.message}");
}
@override
void paywallViewDidFailRestore(AdaptyUIView view, AdaptyError error) async {
await view.dismiss();
onError("Failed restoring purchase: ${error.message}");
}
@override
void paywallViewDidFailRendering(AdaptyUIView view, AdaptyError error) async {
await view.dismiss();
onError("Failed rendering paywall: ${error.message}");
}
@override
void paywallViewDidPerformAction(AdaptyUIView view, AdaptyUIAction action) async {
print(view);
print(action);
// If the user performs a close action, consider it a cancellation.
if (action is CloseAction || action is AndroidSystemBackAction) {
await view.dismiss();
onCancelled();
} else if (action is OpenUrlAction) {
final url = action.url;
final Uri uri = Uri.parse(url);
// Launch the URL in an in-app browser view.
await launchUrl(uri);
}
}
}
On Android it's perfectly working, so why on iOS should require a different approach and where is this specified in Adapty's doc?
@x401om I did some more checks: if I don't pass "..withObserverMode(true)" then it works fine. Strangely, if I still keep the "adaptyUI.setObserver(observer);" even without passing withObserverMode true, then the customObserver continues to work. This is a very weird bug, tbh.
Hey! I think there’s a bit of confusion around how Observer Mode works in Adapty and how it ties into AdaptyUI and PaywallBuilder functionality — let me try to clarify it.
First off, it’s important to understand that Observer Mode is a special mode designed to let developers use the Adapty SDK alongside their existing purchase infrastructure — whether just for analytics or as an intermediate step toward full integration. Originally, the only real difference between Full Mode and Observer Mode was how the Adapty SDK handled transactions: in Observer Mode, we didn’t finish transactions.
Because of this, developers could still call makePurchase (which was a bit odd if they were already handling purchases themselves), but it was entirely their responsibility to deal with the transaction flow. At the same time, Adapty could still observe and report transactions to our backend automatically.
When we launched AdaptyUI and the Paywall Builder, we expected them to be used in Full Mode. Since AdaptyUI internally uses the same makePurchase method, everything mentioned above still applies. Later, we got requests to support AdaptyUI in Observer Mode, so we introduced a way to pass an AdaptyObserverModeResolver to intercept and handle the purchase flow manually for Adapty iOS SDK.
Recently, things changed with our migration to StoreKit 2. SK2 works differently, and because of that, we had to stop listening to transaction updates in Observer Mode — otherwise, we risked breaking app functionality. Now, developers are responsible for reporting all completed transactions to us manually. Because of this shift, the SDK initializes differently in Observer Mode and no longer allows using makePurchase. That’s why you’re seeing an error when trying to use Paywall Builder in Observer Mode — it relies on makePurchase, which now throws in this mode.
As mentioned earlier, there is a workaround using AdaptyObserverModeResolver, but we haven’t yet implemented this for the cross-platform SDKs.
Sorry if the naming caused some confusion — for example, setObserver (a listener for SDK events) vs. withObserverMode (which switches between Observer and Full Mode). They sound related, but they’re completely separate things. We’ll look into renaming them or improving the documentation to avoid this kind of misunderstanding.
I’ve marked your issue as a feature request, and we’ll consider prioritizing support for Paywall Builder in Observer Mode. That way, you can use it without giving up your existing purchase logic.