Skip to main content
Question

How to handle Meta Ads + SKAN + RevenueCat for iOS Subscriptions (Server-to-server vs. On-device conflict)

  • July 21, 2026
  • 6 replies
  • 67 views

Forum|alt.badge.img

Hey everyone,

I’m trying to run Meta paid ads to promote an iOS app, with the primary objective of maximizing for the Start Trial / Subscribe event. I've been struggling with missing signals and could use some advice on the current best practices.

Here is what I’ve tried so far:

  1. Meta SDK + ATT prompt only: Not working well. I'm seeing way too many missing Free Trial/Subscription signals from the ad campaigns.

  2. Meta SDK + RevenueCat + ATT prompt (Pending): RevenueCat does use Meta's CAPI. I am still waiting on Apple App Review to see the outcome of this setup.

The Core Dilemma:

RevenueCat strongly recommend turning off "Log In-App Events Automatically" to avoid double-counting revenue when sending server-to-server data back to Meta.

However, I recently read

https://www.reddit.com/r/FacebookAds/comments/1mpaecw/implementing_skan_along_side_the_meta_ads/

that raised a major concern: If we rely strictly on server-to-server data (RevenueCat to Meta), SKAdNetwork (SKAN) will not be able to get the required data. On-device SKAN fundamentally needs to get data directly from the user's iOS device.

My Question:

If standard practice for revenue backends is to disable automatic event logging to prevent double counting, what is the recommended way for on-device SKAN to still get the required data?

Are we supposed to manually log specific SKAN events on-device while letting the backend handle CAPI? How are you all configuring this without breaking SKAN or double-counting?

Any insights would be hugely appreciated!

6 replies

hussain
RevenueCat Staff
Forum|alt.badge.img+6
  • RevenueCat Staff
  • July 21, 2026

Hi ​@yan,

Thanks for reaching out. I’m happy to help.

The key point is that RevenueCat’s server events and SKAN are two separate signal paths:

  • RevenueCat -> Meta CAPI sends accurate subscription events from the server, including trial starts, conversions, and renewals.
  • SKAN/AdAttributionKit sends anonymous conversion values from the iPhone to Apple. A server cannot update these values directly.

Turning off Meta’s automatic purchase logging does not disable SKAN itself. It only stops Meta’s SDK from automatically sending client-side purchase events. We recommend this because sending the same purchase from both the SDK and CAPI can double-count it.

The safest setup is:

  1. Keep the Meta SDK installed.
  2. Disable automatic purchase/revenue logging.
  3. Let RevenueCat Meta integration send StartTrialSubscribe, and revenue events.
  4. Continue tracking installs/app launches by calling Meta’s activateApp().
  5. If you specifically need those conversions encoded into SKAN, update the SKAN/AdAttributionKit conversion value on-device using Meta’s supported setup, an MMP, or Apple’s APIs.

The important distinction is to update the SKAN conversion value on-device, rather than manually sending the same revenue event to Meta a second time. Meta can only reliably deduplicate client and server events when they share the same event ID, and the Meta SDK and RevenueCat do not do that automatically.

Trial conversion is especially difficult because it may happen while the app is closed. One option is:

  • RevenueCat detects the conversion.
  • Your backend receives a RevenueCat webhook.
  • The backend sends a silent push to the device.
  • The app updates the SKAN conversion value.

This improves coverage, but silent pushes and SKAN privacy thresholds mean it still will not be 100% complete. Because StartTrial happens while the app is open, it is generally a more reliable SKAN optimization event than the later trial-to-paid conversion.

I would also confirm whether the campaign is actually using SKAN or Meta’s AEM. They are separate attribution systems, and the required setup can differ.

Lastly, ensure $fbAnonId is sent to RevenueCat before the first purchase, and collect device identifiers again after ATT permission is granted. For users who decline ATT, RevenueCat’s CAPI integration has a “Send events when ATT consent is not authorized” option. SKAN itself does not require ATT permission.

Hope this helps, let me know if you have any other questions.

Best,

Hussain


Forum|alt.badge.img
  • Author
  • New Member
  • July 21, 2026

Hi ​@hussain ,

Thanks for the detailed explanation. That clarified a lot.

For now, I'd like to focus only on the Start Trial event. I'll ignore the later trial-to-paid conversion for now due to its additional complexity.

In Meta Ads Manager, when optimizing for Start Trial, I can choose between:

- Meta's attribution for iOS 14+ (Aggregated Event Measurement)
- Apple's SKAdNetwork

I'm currently choosing Meta's attribution for iOS 14+ (Aggregated Event Measurement) because Meta describes it as:

"Choose for near real-time reporting on conversions from people using iOS 14+ devices, which is best for most advertisers. SKAdNetwork results will also be available in reporting."

 

My primary goal is to maximize Meta Ads attribution accuracy for the Start Trial event.

Given this setup, is it still important to update the SKAN/AdAttributionKit conversion value on-device?

If yes, could you please explain the recommended implementation? Specifically:

  1. Should I update the SKAN/AdAttributionKit conversion value directly using Apple's APIs?
  2. Or does RevenueCat recommend another approach that avoids sending duplicate Start Trial events to Meta?

I'm trying to ensure I don't lose SKAN optimization signals while also avoiding duplicate conversion events between the Meta SDK and RevenueCat CAPI.

Thanks again for your help!


hussain
RevenueCat Staff
Forum|alt.badge.img+6
  • RevenueCat Staff
  • July 22, 2026

Hi ​@yan,

Since you’re using Meta’s attribution for iOS 14+ (AEM), updating an Apple SKAN conversion value is not required for AEM optimization.

Think of AEM and SKAN as two separate scoreboards:

  • AEM: Meta’s attribution system. RevenueCat sends the StartTrial event to Meta through CAPI.
  • SKAN: Apple’s privacy-based reporting system. Its conversion value must be updated on the device.

For your main goal, I recommend:

  1. Keep the Meta SDK installed and correctly initialized.
  2. Disable automatic in-app event logging/purchase tracking to prevent duplicate events.
  3. Let RevenueCat send StartTrial through Meta CAPI.
  4. Set $fbAnonId in RevenueCat before the trial starts and collect device identifiers.
  5. In Meta Events Manager, confirm that StartTrial is being received and is eligible for AEM.

I would not also log StartTrial through the Meta SDK. Meta requires the SDK and server events to share the same event name and event ID for reliable deduplication. RevenueCat and the Meta SDK do not currently coordinate that event ID.

If you also want StartTrial included in the separate SKAN reporting shown by Meta, you can:

  1. Configure StartTrial in Meta’s SKAN conversion schema in Events Manager.
  2. After RevenueCat confirms a successful trial-starting purchase in the app, update the corresponding conversion value using Apple’s SKAdNetwork API.
  3. Do not send another Meta StartTrial event at that point. The Apple conversion-value update is enough for the SKAN path.

This avoids double-counting because an Apple conversion-value update is not a second CAPI or Meta SDK event.

Make sure only one system updates SKAN values i.e your app, the Meta SDK, or an MMP. Meta warns that using multiple updaters can cause conflicting conversion values and lost signals.

I’d recommend for an AEM campaign, focus on RevenueCat CAPI and AEM eligibility. Add an on-device SKAN update only if you also want accurate SKAN reporting or plan to run SKAN-attributed campaigns. Also, compare AEM and SKAN separately in Ads Manager rather than adding their results together.

Hope this helps, let me know if you have any other questions.

Best,

Hussain


Forum|alt.badge.img
  • Author
  • New Member
  • July 22, 2026

Hi ​@hussain 

Regarding your suggestion to "update the corresponding conversion value using Apple’s SKAdNetwork API":

Did you mean that we should perform the following operation upon a successful purchase?

if Utils.isEligibleForFreeTrial(shop) {
// In Meta Events Manager, we are using (62, low priority) for Start Trial.

if #available(iOS 16.1, *) {
SKAdNetwork.updatePostbackConversionValue(62, coarseValue: .low, lockWindow: false) { error in
if let error = error { error_log(error) }
}
} else {
SKAdNetwork.updatePostbackConversionValue(62) { error in
if let error = error { error_log(error) }
}
}
} else {
// In Meta Events Manager, we are using (63, high priority) for Subscribe.

if #available(iOS 16.1, *) {
SKAdNetwork.updatePostbackConversionValue(63, coarseValue: .high, lockWindow: false) { error in
if let error = error { error_log(error) }
}
} else {
SKAdNetwork.updatePostbackConversionValue(63) { error in
if let error = error { error_log(error) }
}
}
}

 

(The attached image shows the SKAN values in Meta Events Manager).

 

 

Additionally, based on a 1-day observation, the Meta ads attribution rate still seems quite low. For instance, in the last 24 hours, we saw:

  • 26 actual free trials

  • Only 7 free trials reported by Meta Ads

Below is an example of the CAPI payload sent by RevenueCat to Meta. Do you think the information being sent here is correct?

{
"access_token": "<REMOVE>",
"data": [
{
"action_source": "app",
"app_data": {
"advertiser_tracking_enabled": 1,
"application_tracking_enabled": 1,
"extinfo": [
"i2",
"com.yocto.melonote",
"",
"2.81",
"26.5.2",
"",
"zh-CN,zh-Hans;q=0.9",
"",
"",
0,
0,
"",
0,
0,
0,
""
],
"vendor_id": "6099B577-DE09-4D7D-9DCE-B18E64280247"
},
"custom_data": {
"content_ids": [
"weekly_premium"
],
"content_type": "product",
"contents": [
{
"id": "weekly_premium",
"quantity": 1
}
],
"currency": "USD",
"order_id": "ec5aa3003ff10d46c217d5028c0717913a4e51a9",
"value": 0
},
"event_id": "ec5aa3003ff10d46c217d5028c0717913a4e51a9",
"event_name": "StartTrial",
"event_time": 1784723194,
"user_data": {
"anon_id": "XZ0819735C-F23C-4E37-8030-58EA9D5E958C",
"client_ip_address": "103.142.140.151",
"external_id": "3aba2f2c9ad42b0557a4e69b3eca70af78e24874000c85320d55d767042f2032",
"madid": "82393701-1508-44B4-AFC2-07268906E886"
}
}
],
"partner_agent": "revenuecat"
}

Thank you!


hussain
RevenueCat Staff
Forum|alt.badge.img+6
  • RevenueCat Staff
  • July 24, 2026

Hi ​@yan,

Your approach is broadly correct if Meta’s SKAN schema maps StartTrial to fine value 62 and coarse value low.

Calling Apple’s updatePostbackConversionValue API does not send another StartTrial event to Meta. It only updates the value Apple stores on the device, so it will not duplicate the StartTrial event RevenueCat sends through CAPI.

A few important details:

  1. Trigger the update only after the purchase succeeds. Ideally, confirm that a trial was actually applied rather than relying only on a pre-purchase eligibility check. Eligibility means the user can receive a trial, but it does not guarantee that the completed transaction started one.

  2. Use only one SKAN updater. If your app calls Apple’s API directly, configure Meta to use a custom SKAN integration and disable the Meta SDK’s SKAN reporter:

Settings.shared.isSKAdNetworkReportEnabled = false

Otherwise, your code and the Meta SDK may both update the value and overwrite each other. The same rule applies if you later add an MMP.

  1. Your values increase from 62 for StartTrial to 63 for Subscribe, which is good. Meta recommends not lowering conversion values. Keeping lockWindow: false also makes sense if you may send a higher value later.

  2. If you support versions earlier than iOS 15.4, add an availability check and use the older updateConversionValue API as a fallback.

For your AEM campaign, this SKAN code will not improve AEM attribution. It only improves the separate SKAN reporting path. AEM will continue to use the StartTrial events RevenueCat sends through CAPI.

I also checked the recent data for your app:

  • RevenueCat recorded 26 unique production trial starts in the latest 24-hour period.
  • Four did not have either $fbAnonId or $idfa.
  • I found 13 recent StartTrial requests that Meta accepted successfully with HTTP 200.
  • A few older/restored transactions were rejected because their event time was over seven days old. These were not new trials.

The payload you shared looks structurally correct. StartTrialaction_source: app, the identifiers, USD currency, and a value of 0 are expected for a free trial.

The important difference is that “accepted by Meta” does not mean “attributed to an ad.” Meta still needs to match the event to somebody who interacted with a Meta ad and apply the campaign’s attribution window and privacy rules. Organic trials and unmatched users will not appear as attributed conversions. Because of that, comparing all 26 RevenueCat trials directly with the 7 shown in Ads Manager is not necessarily an equal comparison.

The main improvement I recommend is ensuring these are called immediately after configuring RevenueCat and before the paywall or purchase:

Purchases.shared.attribution.collectDeviceIdentifiers()
Purchases.shared.attribution.setFBAnonymousID(AppEvents.shared.anonymousID)
await Purchases.shared.syncAttributesAndOfferingsIfNeeded()

Call collectDeviceIdentifiers() again after ATT is resolved so RevenueCat receives the IDFA when permission is granted. Then check Meta Events Manager’s Diagnostics and Event Match Quality, and allow several days before judging the result. SKAN reporting takes longer because Apple waits for the conversion window and adds a random postback delay.

Hope this helps, let me know if you have any other questions.

Best,
Hussain


Forum|alt.badge.img
  • Author
  • New Member
  • July 24, 2026

Hi ​@hussain 

1. Can you kindly confirm I am following RevenueCat's best practices? 

`initRevenueCat` is called during app startup, just after Facebook SDK initialization.

In `initRevenueCat`, I check `if ATTrackingManager.trackingAuthorizationStatus != .notDetermined` before calling `Purchases.shared.attribution.collectDeviceIdentifiers()`. This is because the documentation mentions, "Device identifiers can't be changed once set." I want to ensure we do not write invalid device identifiers before the ATT status is determined.

 

// https://www.revenuecat.com/docs/integrations/attribution/apple-search-ads
// https://www.revenuecat.com/docs/integrations/attribution/facebook-ads#3-send-revenuecat-events-into-facebook-ads-manager
private func initRevenueCat() {
    Purchases.configure(with:
        .init(withAPIKey: "appl_...")
        .with(
            purchasesAreCompletedBy: .myApp,
            storeKitVersion: .storeKit2
        )
    )
    
    Purchases.shared.attribution.enableAdServicesAttributionTokenCollection()

    // https://www.revenuecat.com/docs/customers/customer-attributes
    // Device identifiers can't be changed once set
    // (That's why we are checking trackingAuthorizationStatus)
    if ATTrackingManager.trackingAuthorizationStatus != .notDetermined {
        // Automatically collect the $idfa, $idfv, and $ip values
        Purchases.shared.attribution.collectDeviceIdentifiers()
    }
    
    // REQUIRED: Set the Facebook anonymous Id
    Purchases.shared.attribution.setFBAnonymousID(FBSDKCoreKit.AppEvents.shared.anonymousID)
    
    
    // https://www.revenuecat.com/docs/integrations/third-party-integrations/firebase-integration
    let instanceID = Analytics.appInstanceID()
    if let unwrapped = instanceID {
        Purchases.shared.attribution.setFirebaseAppInstanceID(unwrapped)
    }
    
    // https://community.revenuecat.com/third-party-integrations-53/how-to-handle-meta-ads-skan-revenuecat-for-ios-subscriptions-server-to-server-vs-on-device-conflict-7761
    Task {
        do {
            let _ = try await Purchases.shared.syncAttributesAndOfferingsIfNeeded()
        } catch {
            error_log(error)
        }
    }
}

private func requestTrackingPermission() {
    ATTrackingManager.requestTrackingAuthorization { status in
        
        // 1. Firebase Analytics
        let isAuthorized = (status == .authorized)
        Analytics.logEvent(isAuthorized ? "att_ok" : "att_nok", parameters: nil)
        
        // 2. Facebook SDK
        if #available(iOS 17.0, *) {
            print("ATT status resolved. Tracking enabled/disabled automatically by Facebook SDK for iOS 17+.")
        } else {
            // Condenses the 4 switch cases into a single boolean assignment
            Settings.shared.isAdvertiserTrackingEnabled = isAuthorized
        }
        
        Purchases.shared.attribution.enableAdServicesAttributionTokenCollection()
        
        // Automatically collect the $idfa, $idfv, and $ip values
        Purchases.shared.attribution.collectDeviceIdentifiers()
        
        // https://community.revenuecat.com/third-party-integrations-53/how-to-handle-meta-ads-skan-revenuecat-for-ios-subscriptions-server-to-server-vs-on-device-conflict-7761
        Task {
            do {
                let _ = try await Purchases.shared.syncAttributesAndOfferingsIfNeeded()
            } catch {
                error_log(error)
            }
        }
        
        // 4. Delegate
        DispatchQueue.main.async {
            delegate?.next()
        }
    }
}

2. I came across an old post. About 5 months ago, your colleague ​@chris_perriam recommended using the Facebook SDK exclusively to send FreeTrial signals instead of the RevenueCat/CAPI integration. His reasoning was that CAPI "comes with the disadvantage of not having the full collection of device/user data associated."

Here is the thread: https://community.revenuecat.com/third-party-integrations-53/deduplicating-start-trial-subscribe-events-for-meta-ads-7428

Could you confirm if relying on RevenueCat/CAPI is still your current recommendation?

I previously followed “Facebook SDK exclusively” advice, but I still saw a large number of FreeTrial signals failing to attribute in Meta Ads. Because of those drop-offs, I'm currently testing the RevenueCat/CAPI integration in hopes that it will provide more accurate attribution. I'd love to hear your thoughts on this approach.

Thank you.