SKAdNetwork 3.0 S2S Implementation Guide

To implement the newer version of SKAdNetwork, see [BETA] SKAdNetwork 4.0 S2S Implementation.

 

SKAdNetwork Overview

SKAdNetwork is a framework provided by Apple to enable privacy-friendly measurement of app install ad campaigns on iOS (learn more). The framework helps to measure conversion rates of app install campaigns without compromising users' identifiers.

How Does SKAdNetwork Work?

In SKAdNetwork, the attribution process is conducted by the App Store through Apple’s servers. The attribution information is then disconnected from user identifiers and temporal information and sent off to the network.

Screen_Shot_2020-09-16_at_18.57.56.png

When an ad is clicked and the store is opened, the publishing app and the network provide some basic information such as network, publisher, and campaign ID. If the advertiser app has launched and registered for SKAdNetwork, the device will then send a notification of successful conversion to the network. It will report the attached values alongside a conversion value that can be reported by the advertised app.

That notification will be sent at least 24 hours after the first launch and will be devoid of any device or user identifying information.

Additionally, the App Store conducts the process so the advertised app has no knowledge of the original ad and publisher. This way, the network is notified about an install without knowing anything about the installing user.

What to Expect When Using SKAdNetwork?

SKAdNetwork has some major advantages. It provides you with all the following information:

  • Last click attribution that works without consent
  • Source, campaign, and publisher breakdowns
  • Post-install conversion values (up to 64 discrete values)
  • Cryptographic signatures to validate installs

However, in its current form, SKAdNetwork is bare-bones and requires careful implementation and coordination between multiple entities to ensure that it works.

Here are some of SKAdNetwork's current limitations:

  • No user-level data
  • No view-through attribution
  • Limited range of conversion values:
    • A single conversion event per install/re-install
    • Up to 64 conversion values (6 bits)
  • Limited granularity:
    • Up to 100 campaign values
    • No representation for the ad group and creative level
  • No LTV / long cohorts
  • Fraud exposure:
    • The conversion value itself is not signed (can be manipulated)
    • Postbacks can be duplicated.

To overcome some of these issues, Singular has released a public standard for SKAdNetwork implementation, as well as several blog posts that can help you navigate SKAdNEtwork:

SKAdNetwork S2S Implementation

Singular SKAdNetwork solution consists of the following components:

  • Client-side code to implement SKAdNetwork
  • Postback validation and aggregation from all networks
  • Fraud protection:
    • Signature validation and transaction ID deduping.
    • Secure setup with networks to verify parameters which are not signed (conversion value and geodata).
  • Conversion value management: provides the ability to configure dynamically on Singular's dashboard which post-install activity should be encoded into the SKAdNetwork conversion value.
  • Reporting: Translating the limited SKAdNetwork campaign ID and enriching the data with more marketing params and granularities
  • Partner postbacks: It provides sending SKAdNetwork postbacks with a decoded conversion value into events and revenue, which is critical for their optimization.

For customers using a server-to-server Singular integration, SKAdNetwork implementation consists of two main parts:

  1. SKAdNetwork client-side implementation: This part is critical for registering your apps to SKAdNetwork and managing the SKAdNetwork conversion value intelligently. This means that by implementing it, you will be able to optimize your campaigns based on SKAdNetwork attributions and their associated post-install activities.
  2. S2S integration update (optional): This part is important for validating and troubleshooting the client-side implementation. By enriching events and sessions sent to SIngular today over Singular's S2S session and event endpoints with SKAdNetwork data (conversion values and update timestamps), Singular can validate the implementation was done properly on your app side.

SKAdNetwork Client-Side Implementation

Singular provides code samples that support registering for SKAdNetwork and managing the conversion value. These code samples are responsible for the following parts:

  1. SKAdnetwork support and registration
  2. Conversion value management:
    • The code communicates synchronously with a Singular's endpoint to receive the next conversion value based on the configured conversion model. It reports events/sessions/revenue and, in response, gets the next conversion value, which is an encoded number that represents the post-install activity that was configured for measurement in Singular's dashboard.
    • The code also collects SKAdnetwork metadata that is used for both validation and next conversion value calculation:
      • The first call timestamp to the underlying SKAdNetwork framework
      • The last call timestamp to the underlying SKAdNetwork framework
      • The last updated conversion value

S2S Integration Update

Mandatory

Once you have an active conversion model, our S2S endpoints will start returning a new int field named "conversion_value", which will contain the next value to update in the client side code.

Optional

To validate the integration and troubleshoot potential implementation issues, we recommend using our code samples to extend your current S2S integration. Singular's S2S event and session endpoints already support getting additional SKAdNetwork params such as:

  • Latest conversion value
  • Last timestamp of a call to the underlying SKAdNetwork framework
  • First timestamp of a call to the underlying SKAdNetwork framework

Integration Flow

Screen_Shot_2020-09-16_at_18.59.13.png

The diagram above illustrates the SKAdNetwork flow for an S2S customer:

  1. First, the code in the app communicates with a Singular server (through a dedicated endpoint for SKAdNetwork) to get the latest conversion value synchronously based on events/sessions/revenue events that happen in the app and update the SKAdNetwork framework with this value.
  2. Second, the app enriches existing events and sessions with SKAdNetwork data, which will be later used for validations.
  3. Once the app finishes updating the SKAdNetwork framework with new conversion values and the SKAdNetwork timer expires, the SKAdNetwork postback will be sent to the network.
  4. The network will forward it to Singular (either via the secure setup or regular setup).
  5. Singular will process the postback by:
    • Validating its signature
    • Decoding the conversion value based on the configured conversion model
    • Enriching the postback with more outward-facing marketing params based on integration with partners and the SKAdNetwork internal campaign ID
    • Sending the decoded postbacks to BI and partners

An important note is that SKAdNetwork information, including installs and decoded events, will be accessible via a different set of reports/APIs/ETL tables and postbacks to avoid mixing it with your existing data sets. This is especially important during the following weeks to let you measure and test SKAdNetwork side by side with your existing campaign effort.

SKAdNetwork Native iOS Code Samples

SKAdNetwork interface

This interface includes the following SKAdNetwork components:

Registering for SKAdNetwork:

  • This method is responsible for registering your app for SKAdNetwork. The underlying Apple API method generates a notification if the device has attribution data for that app, and starts a 24-hour timer.
  • The device sends the install notification to the ad network's postback endpoint within 0-24 hours after the timer expires.
  • Note that if you update the conversion value with a greater value than the previous one, it resets the first timer for a new 24 hours interval (read more here).

Conversion value management and updates:

  • The conversion value is calculated based on the post-install activity of a device that is captured by the methods below and a selected conversion model that can be configured dynamically by the user.
  • The methods in this section are responsible for pulling the next conversion value from Singular's endpoint according to the selected conversion model and the reported post-install activity (see documentation above).
  • The methods below update the conversion value based on the following post-install activities:
      • Session: critical for retention measurement of a user with SKAdNetwork
      • Conversion Event: critical for post-install conversion event measurement with SKAdNetwork
      • Revenue Event: Critical for revenue measurement with SKAdNetwork
//  SKANSnippet.h

#import <Foundation/Foundation.h>

@interface
SKANSnippet : NSObject

// Register for SkAdNetwork attribution.
// You should call this method as soon as possible once app is launched
+ (void)registerAppForAdNetworkAttribution;

// To track retention and cohorts you need to call this method after 
// each session. It reports the session details and updates the conversion
// value due to this session if needed. The conversion value will be
// updated only if the new value is greater than the previous value. // The callback passed to the method is optional, you can use it to
// run code once the conversion value is updated. + (void)updateConversionValueAsync:(void(^)(int, NSError*))handler; // To track conversion events with SKAdNetwork you need to call this
// method after each event. It reports the event details and updates the
// conversion value due to this event if needed. // The callback passed to the method is optional, you can use it to
// run code once the conversion value is updated. + (void)updateConversionValueAsync:(NSString*)eventName
withCompletionHandler:(void(^)(int, NSError*))handler; // To track revenue with SKAdNetwork you need to call this
// method before each revenue event . // It will update the total revenue, so when you call 'updateConversionValueAsync',
// the new conversion value will be determined according to the total amount of revenue. // Note: // 1. Call this method before calling 'updateConversionValueAsync' to
// make sure that revenue is updated. // 2. In case of retrying an event, avoid calling this method
// so the same revenue will not count twice. + (void)updateRevenue:(double)amount andCurrency:(NSString*)currency; // Gets the current conversion value (nil if none) + (NSNumber *)getConversionValue; @end

SKAdNetwork interface implementation

//  SKANSnippet.m

#import "SKANSnippet.h"
#import <StoreKit/SKAdNetwork.h>

#define SESSION_EVENT_NAME @”__SESSION__” 
#define SINGULAR_API_URL @"https://sdk-api-v1.singular.net/api/v1/conversion_value"

// Keys for UserDefaults storage
#define CONVERSION_VALUE_KEY @"skan_conversion_value"
#define FIRST_SKAN_CALL_TIMESTAMP @"skan_first_call_to_skadnetwork_timestamp"
#define LAST_SKAN_CALL_TIMESTAMP @"skan_last_call_to_skadnetwork_timestamp"
#define TOTAL_REVENUE_BY_CURRENCY_KEY @"skan_total_revenue_by_currency"
#define SECONDS_PER_DAY 86400

@implementation
SKANSnippet

+ (void)registerAppForAdNetworkAttribution {
    if ([SKANSnippet getFirstSkanCallTimestamp] != 0) {
        return;
    }

    if (@available(iOS 11.3, *) ) {
        [SKAdNetwork registerAppForAdNetworkAttribution];
        [SKANSnippet setFirstSkanCallTimestamp];
        [SKANSnippet setLastSkanCallTimestamp];
    }
}

+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler {
    [SKANSnippet updateConversionValueAsync:SESSION_EVENT_NAME withCompletionHandler:handler];
}

+ (void)updateConversionValueAsync:(NSString*)eventName withCompletionHandler:(void(^)(int, NSError*))handler {
    if (@available(iOS 14, *)) {
        if ([SKANSnippet isSkanUpdateWindowOver]) {
            return;
        }
        
        [SKANSnippet getConversionValueFromServer:eventName withCompletionHandler:handler];
    }
}

+ (void)updateRevenue:(double)amount andCurrency:(NSString*)currency {
    if (@available(iOS 14, *)) {
        
        // Update total revenues
        if (amount != 0 && currency) {
            NSMutableDictionary* revenues = [[SKANSnippet getTotalRevenue] mutableCopy];
            NSNumber* currentRevenue = 0;
            
            if ([revenues objectForKey:currency]) {
                currentRevenue = [revenues objectForKey:currency];
            }
            
            currentRevenue = @([currentRevenue floatValue] + amount);
            [revenues setObject:currentRevenue forKey:currency];
            
            [SKANSnippet setTotalRevenue:revenues];
        }
    }
}

+ (NSNumber *)getConversionValue {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    if (![userDefaults objectForKey:CONVERSION_VALUE_KEY]) {
        return nil;
    }
    return @([userDefaults integerForKey:CONVERSION_VALUE_KEY]);
}

+ (void)getConversionValueFromServer:(NSString*)eventName withCompletionHandler:(void(^)(int, NSError*))handler {
    if (!lockObject) {
        lockObject = [NSLock new];
    }
    
    // Making the lock async so it will not freeze the calling thread
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
        
        [lockObject lock];
        
        NSURLComponents *components = [NSURLComponents componentsWithString:SINGULAR_API_URL];
        
        NSString* bundleIdentifier = @"";
        
        components.queryItems = @[
            [NSURLQueryItem queryItemWithName:@"a" value:@""],
            [NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
            [NSURLQueryItem queryItemWithName:@"app_v" value:@""],
            [NSURLQueryItem queryItemWithName:@"n" value:eventName],
            [NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
            [NSURLQueryItem queryItemWithName:@"idfv" value:@""],
            [NSURLQueryItem queryItemWithName:@"idfa" value:@""],
            [NSURLQueryItem queryItemWithName:@"conversion_value" 
value:[[SKANSnippet getConversionValue] stringValue]], [NSURLQueryItem queryItemWithName:@"total_revenue_by_currency"
value:[SKANSnippet dictionaryToJsonString:[SKANSnippet getTotalRevenue]]], [NSURLQueryItem queryItemWithName:@"first_call_to_skadnetwork_timestamp"
value:[NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]]], [NSURLQueryItem queryItemWithName:@"last_call_to_skadnetwork_timestamp"
value:[NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]]], ]; [[[NSURLSession sharedSession] dataTaskWithURL:components.URL completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { if (error) { [lockObject unlock]; if (handler) { handler(-1, error); } return; } NSDictionary* parsedResponse = [SKANSnippet jsonDataToDictionary:data]; if (!parsedResponse) { [lockObject unlock]; if (handler) { handler(-1, [NSError errorWithDomain:bundleIdentifier code:0 userInfo:@{NSLocalizedDescriptionKey:@"Failed parsing server response"}]); } return; } NSNumber *conversionValue = [parsedResponse objectForKey:@"conversion_value"]; if (!conversionValue) { [lockObject unlock]; NSString *status = [parsedResponse objectForKey:@"status"]; if (!status || ![status isEqualToString:@"ok"]) { if (handler) { NSString *reason = [parsedResponse objectForKey:@"reason"]; if (!reason) { reason = @"Got error from server"; } handler(-1, [NSError errorWithDomain:bundleIdentifier code:0 userInfo:@{NSLocalizedDescriptionKey:reason}]); } } return; } NSNumber* currentValue = [SKANSnippet getConversionValue]; if ([conversionValue intValue] <= [currentValue intValue]) { [lockObject unlock]; return; } [SKANSnippet setConversionValue:[conversionValue intValue]]; [SKANSnippet setLastSkanCallTimestamp]; if (![SKANSnippet getFirstSkanCallTimestamp]) { [SKANSnippet setFirstSkanCallTimestamp]; } [lockObject unlock]; if (handler) { handler([conversionValue intValue], error); } }] resume]; }); } + (BOOL)isSkanUpdateWindowOver { NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] -
[SKANSnippet getLastSkanCallTimestamp]; return SECONDS_PER_DAY <= timeDiff; }

SKAdNetwork Metadata and Utility Methods

This section is responsible for implementing utilities that are being used in the implementation above. Note that these utilities are critical to maintain and store the metadata required to calculate the next conversion values.

The following utilities are implemented:

Saving values to UserDefaults (and retrieving them):

  • First call timestamp to the underlying SKAdNetwork API
  • Last call timestamp to the underlying SKAdNetwork API
  • Total revenue by currency

Converting values between different representations:

  • Dictionary to JSON string
  • JSON to dictionary
+ (NSInteger)getFirstSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:FIRST_SKAN_CALL_TIMESTAMP];
}

+ (NSInteger)getLastSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:LAST_SKAN_CALL_TIMESTAMP];
}

+ (NSDictionary*)getTotalRevenue {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    if (![userDefaults objectForKey:TOTAL_REVENUE_BY_CURRENCY_KEY]) {
        return [NSDictionary new];
    }
    return [userDefaults objectForKey:TOTAL_REVENUE_BY_CURRENCY_KEY];
}

+ (NSInteger)getCurrentUnixTimestamp {
    return [[NSDate date]timeIntervalSince1970];
}

+ (void)setConversionValue:(int)value {
    if (@available(iOS 14.0, *)) {
        if (value <= [[SKANSnippet getConversionValue] intValue]) {
            return;
        }
        
        [SKAdNetwork updateConversionValue:value];
        
        NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
        [userDefaults setInteger:value forKey:CONVERSION_VALUE_KEY];
        [userDefaults synchronize];
    }
}

+ (void)setFirstSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] 
forKey:FIRST_SKAN_CALL_TIMESTAMP]; [userDefaults synchronize]; } + (void)setLastSkanCallTimestamp { NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults]; [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp]
forKey:LAST_SKAN_CALL_TIMESTAMP]; [userDefaults synchronize]; } + (void)setTotalRevenue:(NSDictionary *)values { NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults]; [userDefaults setObject:values forKey:TOTAL_REVENUE_BY_CURRENCY_KEY]; [userDefaults synchronize]; } + (NSString*)dictionaryToJSONString:(NSDictionary*)dictionary { if (!dictionary || [dictionary count] == 0){ return @"{}"; } NSError *error; NSData *JSONData = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&error]; if (error || !JSONData) { return @"{}"; } return [[NSString alloc] initWithData:JSONData
encoding:NSUTF8StringEncoding]; } + (NSDictionary*)JSONDataToDictionary:(NSData*)JSONData { if (!JSONData) { return nil; } NSError * error; NSDictionary * parsedData = [NSJSONSerialization
JSONObjectWithData:JSONData options:kNilOptions error:&error]; if (error || !parsedData) { return nil; } return parsedData; } @end

S2S Integration Update - Implementation (Optional)

Update your S2S integration with the following SKAdNetwork metadata (this metadata should be forwarded on every session & event reported to Singular for SKAdNetwork implementation validation):

  • skan_conversion_value - The latest conversion value
  • skan_first_call_timestamp - Unix timestamp of the first call to the underlying SkAdNetwork API
  • skan_last_call_timestamp - Unix timestamp of the latest call to the underlying SkAdNetwork API

The following code snippet shows how to extract these values:

NSDictionary *values = @{
    @"skan_conversion_value":
      [[SKANSnippet getConversionValue] stringValue]],
    @"skan_first_call_timestamp": 
[NSString stringWithFormat:@"%ld", [SKANSnippet getFirstSkanCallTimestamp]], @"skan_last_call_timestamp":
[NSString stringWithFormat:@"%ld", [SKANSnippet getLastSkanCallTimestamp]] };

Now once you send these parameters to your server side, you can forward these via our server-to-server API endpoints. To learn more, search these parameters in our server-to-server API reference.

App Lifecycle Flow Example

// On app launch
[SKANSnippet registerAppForAdNetworkAttribution];

// After each session is handled
[SKANSnippet updateConversionValueAsync:handler];

// After handling non-revenue events
[SKANSnippet updateConversionValueAsync:@"event_name" 
withCompletionHandler:handler]; // After handling revenue events [SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"]; [SKANSnippet updateConversionValueAsync:@"revenue_event_name" withCompletionHandler:handler];

Code Change-log

  • 1 Oct 2020
    • Fix [IMPORTANT]: the lock mechanism in `getConversionValueFromServer` did not init correctly
    • Improved: returning fail reason from our server response
  • 23 Sep 2020
    • Fix [IMPORTANT]: `setConversionValue` now calls `updateConversionValue` 
    • Improved: error handling when retrieving the latest conversion value from the server
  • 15 Sep 2020
    • Changed [IMPORTANT]: If conversion value is not set, the default value returned by `getConversionValue` is 0 instead of null 
    • Improved: Revenue reporting and handling
    • Improved: Retrieving next conversion value asynchronously

Testing Your Implementation

Conversion Management

To simulate your app flow and to test your implementation, replace the conversion management production endpoint (SINGULAR_API_URL) with the following test endpoint - 

A suggested test flow:

  • Generate 3 different events within your app.
      • If everything is implemented correctly -
        • After each event updateConversionValueAsync is supposed to be called (make sure your default conversion value is initialized to 0).
        • The test endpoint receives the current conversion value from the app and returns the next value.
    • Log the returned conversion value to see that everything is working as expected.
      • The test endpoint is implemented to return values between 0-2.
      • Therefore after the third call, it will return an empty string, and the last value will be used as the final SKAdNetwork conversion value.