If you run ads for a mobile app, iOS is where the money is. iOS users spend 2 to 3 times more than Android users on in-app purchases and subscriptions. They convert better, retain longer, and have higher lifetime value. In most markets, the top revenue apps make 60-70% of their income from iOS — even when Android has more downloads.
If you are spending money on Meta ads, iOS users are the ones you want to reach. And that is exactly where the problem starts.
I spent tons of money on Meta ads last year.
A big chunk of that was wasted. Not because the ads were bad, but because Facebook never saw the conversions. My campaigns could not optimize because the conversions events were not getting through.
If you are running Meta ads for a Flutter app on iOS, you have probably seen this: your app is making sales, but Events Manager shows almost nothing. Your ROAS looks terrible. Your campaigns stay stuck in learning phase.
The problem is not your ads (or it might be). The problem is your event pipeline.
The iOS 14+ problem nobody talks about
When Apple introduced App Tracking Transparency (ATT), it changed everything for mobile advertisers. Users can now opt out of tracking — and most of them do. On average, only 20-30% of iOS users opt in.
That means 70-80% of your conversions are invisible to Meta.
Meta's answer to this is AEM — Aggregated Event Measurement. It is a protocol that lets Meta receive conversion data in a privacy-compliant way, even from users who opted out of tracking. Without AEM, Meta literally cannot see most of your iOS purchases.
Here is what happens without AEM:
- use Apple SkanNetwork for attribution, which only reports app installs and a few post-install events with heavy limitations
- You spend $100 on ads
- 10 users buy your app
- Meta sees 2 or 3 of those purchases (the ones who opted in but with 24-48 hours delay)
- Meta thinks your ads are performing terribly
- The algorithm stops showing your ads to good prospects
- You waste more money
With AEM:
- You spend $100 on ads
- 10 users buy your app
- Meta sees 2-3 purchases directly from opted-in users (same as before but only 30 mn to 24 hours delay)
- For the other 7-8, AEM provides aggregated, delayed signals — not full conversion records, but enough for Meta's algorithm to estimate total conversions
- Meta uses statistical modeling on top of these signals to better optimize your campaigns
- Your campaigns improve over time — not perfectly, but dramatically better than flying blind
To be clear: AEM does not magically bypass ATT. It does not send individual conversion data for users who opted out. What it does is give Meta enough aggregated signal to work with. There are real limitations:
- You are limited to 8 prioritized conversion events per app — only the highest-priority event per user gets reported
- Data from opted-out users is delayed 24-72 hours, not real-time
- Meta fills gaps with modeling and estimation, not actual tracked data
- It works alongside Apple's SKAdNetwork postbacks
The bottom line: without AEM, Meta sees almost nothing. With AEM, Meta gets enough signal to actually optimize. It is not perfect, but it is the difference between campaigns that learn and campaigns that burn money.
This is not optional if you are serious about running ads.
Why I built facebook_flutter_sdk
The existing flutter_facebook_app_events plugin is solid and works well for basic event tracking. But it does not support AEM.
I needed AEM for my own campaigns. I submitted a PR to add it, but the maintainer had different priorities — totally fair, it is their project. So I forked it and published facebook_flutter_sdk with AEM built in.
To be clear:
- If you don't run Meta ads or don't need AEM,
flutter_facebook_app_eventsworks great - If you run Meta ads and need accurate iOS conversion tracking,
facebook_flutter_sdkfills that gap
The API is compatible. Switching is a one-line change in your pubspec.
Setup
1. Install the package
flutter pub add facebook_flutter_sdk
2. Get your Facebook credentials
Go to Meta for Developers, open your app, and grab:
- App ID — found in Settings > Basic
- Client Token — found in Settings > Advanced
3. Android configuration
Add your credentials to android/app/src/main/res/values/strings.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="facebook_app_id">YOUR_APP_ID</string>
<string name="facebook_client_token">YOUR_CLIENT_TOKEN</string>
</resources>
Then reference them in your AndroidManifest.xml inside the <application> tag:
<meta-data
android:name="com.facebook.sdk.ApplicationId"
android:value="@string/facebook_app_id" />
<meta-data
android:name="com.facebook.sdk.ClientToken"
android:value="@string/facebook_client_token" />
4. iOS configuration
Update your ios/Runner/Info.plist:
<key>FacebookAppID</key>
<string>YOUR_APP_ID</string>
<key>FacebookClientToken</key>
<string>YOUR_CLIENT_TOKEN</string>
<key>FacebookDisplayName</key>
<string>Your App Name</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>fbYOUR_APP_ID</string>
</array>
</dict>
</array>
Note the fb prefix before your App ID in the URL scheme. This is required by the Facebook SDK.
5. Request ATT permission (iOS)
You need to ask users for tracking permission before sending events. You probably already use permission_handler in your app — it supports ATT out of the box:
flutter pub add permission_handler
Add the usage description to your Info.plist:
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads to you.</string>
Then request permission early in your app — typically after onboarding:
import 'package:permission_handler/permission_handler.dart';
import 'package:facebook_flutter_sdk/facebook_flutter_sdk.dart';
Future<void> requestTracking() async {
final status = await Permission.appTrackingTransparency.request();
final facebookAppEvents = FacebookAppEvents();
// Tell Facebook SDK whether the user opted in
await facebookAppEvents.setAdvertiserTracking(
enabled: status.isGranted,
);
}
Sending events
Initialize the SDK
final facebookAppEvents = FacebookAppEvents();
I usually create this as a singleton in my dependency injection setup so it is available everywhere.
Log custom events
await facebookAppEvents.logEvent(
name: 'level_completed',
parameters: {
'level': '5',
'score': '1200',
},
);
Log purchases
This is the most important event for ad optimization. Use the eventId parameter for deduplication:
await facebookAppEvents.logPurchase(
amount: 9.99,
currency: 'USD',
eventId: transactionId, // unique ID for deduplication
);
The eventId is critical. If you also send purchase events server-side via the Conversions API (you should), Meta uses this ID to deduplicate and avoid counting the same purchase twice.
Log add-to-cart events
await facebookAppEvents.logAddToCart(
id: 'premium_yearly',
type: 'product',
price: 49.99,
currency: 'USD',
);
Set user data for advanced matching
Advanced matching improves attribution accuracy by letting Meta match events to users across devices:
await facebookAppEvents.setUserData(
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
country: user.country,
);
Hash the data yourself if you prefer — Meta also accepts pre-hashed values.
RevenueCat integration
If you use RevenueCat for subscriptions, here is how to log purchases to Meta:
...
final facebookAppEvents = FacebookAppEvents();
final purchaseParams = PurchaseParams.package(package);
final res = await Purchases.purchase(purchaseParams);
if (res.purchaserInfo.entitlements.all.isNotEmpty) {
final transactionId = res.storeTransaction.transactionIdentifier;
await logMetaPurchase(transactionId, package.storeProduct.price, package.storeProduct.currencyCode);
}
The transactionIdentifier from RevenueCat is perfect as an eventId.
If you also send this purchase server-side via RevenueCat's webhook to your backend (which then calls the Conversions API), use the same transaction ID. Meta will deduplicate automatically.
Event deduplication with Conversions API
Client-side events alone are not enough. Some events get lost due to network issues, app crashes, or ad blockers. That is why Meta recommends sending events from both client and server.
The flow:
- Client-side: your Flutter app sends the event via
facebook_flutter_sdkwith aneventId - Server-side: your backend sends the same event via the Conversions API with the same
eventId - Meta deduplicates: if both events arrive, Meta keeps one and discards the duplicate
This gives you the best of both worlds — real-time client events plus reliable server events as a fallback.
If you use RevenueCat, you can set up a webhook from RevenueCat to your server, then forward purchase events to the Conversions API with the same transaction ID.
Why you probably don't need an MMP
When I was trying to figure out why my events were not working, every article and every "expert" told me the same thing: use an MMP.
MMPs like Adjust, AppsFlyer, or Branch are tools that sit between your app and ad networks. They handle attribution, event forwarding, and reporting. They are powerful. They are also expensive — $500 to $2,000+ per month after 10k monthly active users.
But they are not magic, and most of the. time they are really hard to set up correctly.
Here is the thing: if Meta is your primary ad channel, you don't need one.
The facebook_flutter_sdk + Conversions API combo gives you:
- Accurate conversion tracking with AEM
- Event deduplication
- Advanced matching
- Full control over your data
MMPs make sense when you run campaigns across 5+ ad networks simultaneously and need unified attribution. For most indie developers and small teams running Meta ads, direct integration is enough.
Common mistakes
Not requesting ATT early enough
If you request ATT after the user has already triggered events, those early events are sent without tracking authorization. Request it during onboarding, before any purchase flow.
Forgetting the eventId on purchases
Without eventId, Meta cannot deduplicate client and server events. You either miss events or double-count them. Both destroy your campaign optimization.
Volume of Advertiser Tracking Enabled parameter out of range
Many sees this error in their facebook events manager. The error is not really understandable. But this is what you get when plugin is not properly implementing AEM protocol.
When you get this error you can't start a campaign to optimize for purchases. You can only optimize for install or user SKAdNetwork events. That is a problem because purchase optimization is where the money is.
So if you see this error, switch to facebook_flutter_sdk and it should fix it.
Go further with Meta Ads
Setting up events is step one. But creating campaigns that actually convert — choosing the right objective, structuring ad sets, estimating budgets, analyzing results — that is a whole other skill.
I put everything I learned from spending $$$ on Meta ads into a complete guide for Flutter developers.
It covers setup, campaign strategy, budget estimation, and how to avoid the mistakes that cost me thousands.
If you are serious about growing your app with ads, check it out.
Stop flying blind
Every day you run Meta ads without AEM, you are paying for data that never reaches the algorithm. Your campaigns cannot optimize. Your ROAS stays flat. You burn money.
The fix is straightforward: install facebook_flutter_sdk, set up your events with proper deduplication, and let Meta actually see your conversions.
One package. A few lines of code. Campaigns that finally learn.