[BETA] SKAdNetwork 4.0 S2S Implementation

SKAdNetwork is a framework provided by Apple to enable privacy-friendly measurement of app install ad campaigns on iOS. SKAdNetwork 4.0 is the newest version of the framework, which differs from SKAdNetwork 3.0 in several important details.

Use this guide to implement SKAdNetwork 4.0 as part of your Singular S2S integration.

SKAdNetwork 4.0 S2S Implementation

Singular's 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 that 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 (S2S) 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: 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 that 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 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 by measurement period. The metadata is used for both validation and the 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 postbacks value ( both Coarse and Fine) 
      • Total Revenue and Admon Revenue generated by the user  

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.

Recommended 

To validate the integration and support additional insights, 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:

  • P0,P1,P2 conversion values fine and coarse 
  • P0,P1,P2  previous conversion values fine and coarse 
  • Last timestamp of a call to the underlying SKAdNetwork framework
  • First timestamp of a call to the underlying SKAdNetwork framework
  • Aggregated Revenue per window measurement period  (P0, P1, P2) 

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 network information. The data is collected from integrations with partners by joining the SKAdNetwork and Network 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 calling SKAdNetwork for the first time.
  • It sets the Conversion value for P0 to be 0
  • The underlying Apple API method generates a notification if the device has attribution data for that app. 

Conversion Value Management

  • 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 the app is launched for the first time.
// This function sets the conversion value to be 0 and updates the timestamp for additional processing.
+ (void)registerAppForAdNetworkAttribution;

// To track retention and cohorts you need to call this method for each app open.
// It reports the session details and updates the conversion value due to this session if needed.
// The callback passed to the method is optional, you can use it to run code once the conversion value is updated.
//
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// To track conversion events with SKAdNetwork you need to call this method after each event and before this event is sent to Singular.
// 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)updateConversionValuesAsync:(NSString *)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// To track revenue with SKAdNetwork you need to call this method before every revenue event.
// It will update the total revenue, so when you call 'updateConversionValuesAsync', the new conversion value will be determined according to the total amout of revenue.
// Note:
// 1. Call this method before calling 'updateConversionValuesAsync' to make sure that revenue is updated.
// 2. In case of retrying an event, avoid calling this method so the same revenue will not be counted twice.
+ (void)updateRevenue:(double)amount andCurrency:(NSString *)currency isAdMonetization:(BOOL)admon;

// Gets current fine, coarse, window locked values. saved in the dictionary under "fineValue", "coarseValue", "windowLock".
// In addition, contains all other relevant values for SKAN purposes.
// e.g. 
// {
//    "skan_current_conversion_value": 3,
//    "prev_fine_value": 2,
//    "skan_first_call_to_skadnetwork_timestamp": 167890942,
//    "skan_last_call_to_skadnetwork_timestamp": 167831134,
//    "skan_total_revenue_by_currency": { "USD": 1.2 },
//    "skan_total_admon_revenue_by_currency": { "USD": 0.8 },
//    "p0_coarse": 0,
//    "p1_coarse": 1,
//    "p2_coarse": nil,
//    "p0_window_lock": 167890942,
//    "p1_window_lock": nil,
//    "p2_window_lock": nil,
//    "p0_prev_coarse_value": 0,
//    "p1_prev_coarse_value": 0,
//    "p2_prev_coarse_value": nil,
//    "p0_total_iap_revenue": nil,
//    "p1_total_iap_revenue": nil,
//    "p2_total_iap_revenue": nil,
//    "p0_total_admon_revenue": nil,
//    "p1_total_admon_revenue": nil,
//    "p2_total_admon_revenue": nil
// }
+ (NSDictionary *)getSkanDetails;

 @end

SKAdNetwork Interface Implementation

//  SKANSnippet.m

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

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

// SKAN Keys for NSUserDefaults persistency and requests
#define CONVERSION_VALUE_KEY @"skan_current_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 @"skan_total_revenue_by_currency"
#define TOTAL_ADMON_REVENUE_BY_CURRNECY @"skan_total_admon_revenue_by_currency"
#define SKAN_UPDATED_CONVERSION_VALUE @"conversion_value"
#define SKAN_UPDATED_COARSE_VALUE @"skan_updated_coarse_value"
#define SKAN_UPDATED_LOCK_WINDOW_VALUE @"skan_updated_lock_window_value"

#define P0_COARSE @"p0_coarse"
#define P1_COARSE @"p1_coarse"
#define P2_COARSE @"p2_coarse"
#define P0_WINDOW_LOCK_TS @"p0_window_lock"
#define P1_WINDOW_LOCK_TS @"p1_window_lock"
#define P2_WINDOW_LOCK_TS @"p2_window_lock"

#define P0_PREV_FINE_VALUE @"prev_fine_value"
#define P0_PREV_COARSE_VALUE @"p0_prev_coarse_value"
#define P1_PREV_COARSE_VALUE @"p1_prev_coarse_value"
#define P2_PREV_COARSE_VALUE @"p2_prev_coarse_value"

#define TOTAL_REVENUE_P0 @"p0_total_iap_revenue"
#define TOTAL_REVENUE_P1 @"p1_total_iap_revenue"
#define TOTAL_REVENUE_P2 @"p2_total_iap_revenue"
#define TOTAL_ADMON_REVENUE_P0 @"p0_total_admon_revenue"
#define TOTAL_ADMON_REVENUE_P1 @"p1_total_admon_revenue"
#define TOTAL_ADMON_REVENUE_P2 @"p2_total_admon_revenue"

@implementation SKANSnippet

static NSInteger firstSkan4WindowInSec = 3600 * 24 * 2; //48 hours in sec
static NSInteger secondSkan4WindowInSec = 3600 * 24 * 7;
static NSInteger thirdSkan4WindowInSec = 3600 * 24 * 35;

static NSLock *lockObject;

+ (void)registerAppForAdNetworkAttribution {
    if ([SKANSnippet getFirstSkanCallTimestamp] != 0) {
        return;
    }
    
    if (@available(iOS 15.4, *)) {
        [SKAdNetwork updatePostbackConversionValue:0 completionHandler:nil];
        [SKANSnippet setFirstSkanCallTimestamp];
        [SKANSnippet setLastSkanCallTimestamp];
        [SKANSnippet valuesHasBeenUpdated:@(0) coarseValue:nil lockWindow:NO];
    } 
}

+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler {
    [SKANSnippet updateConversionValuesAsync:SESSION_EVENT_NAME withCompletionHandler:handler];
}

+ (void)updateConversionValuesAsync:(NSString *)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler {
        if ([SKANSnippet isSkanWindowOver]) {
            return;
        }
        
        [SKANSnippet getConversionValueFromServer:eventName withCompletionHandler:handler];
}

+ (void)updateRevenue:(double)amount andCurrency:(NSString *)currency isAdMonetization:(BOOL)admon {
    // Update total revenues
    if (amount == 0 || !currency ) {
        return;
    }
    
    [SKANSnippet addToTotalRevenue:@(amount) withCurrency:currency isAdmon:admon];
}

+ (NSDictionary *)getSkanDetails {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSMutableDictionary *res = [NSMutableDictionary dictionary];
    //current fine
    [res setValue:[[userDefaults valueForKey:CONVERSION_VALUE_KEY] stringValue] forKey:CONVERSION_VALUE_KEY];
    //prev fine
    [res setValue:[[userDefaults valueForKey:P0_PREV_FINE_VALUE] stringValue] forKey:P0_PREV_FINE_VALUE];
    //current coarse
    [res setValue:[[userDefaults valueForKey:P0_COARSE] stringValue] forKey:P0_COARSE];
    [res setValue:[[userDefaults valueForKey:P1_COARSE] stringValue] forKey:P1_COARSE];
    [res setValue:[[userDefaults valueForKey:P2_COARSE] stringValue] forKey:P2_COARSE];
    //prev coarse
    [res setValue:[[userDefaults valueForKey:P0_PREV_COARSE_VALUE] stringValue] forKey:P0_PREV_COARSE_VALUE];
    [res setValue:[[userDefaults valueForKey:P1_PREV_COARSE_VALUE] stringValue] forKey:P1_PREV_COARSE_VALUE];
    [res setValue:[[userDefaults valueForKey:P2_PREV_COARSE_VALUE] stringValue] forKey:P2_PREV_COARSE_VALUE];
    //lock windows ts
    [res setValue:[[userDefaults valueForKey:P0_WINDOW_LOCK_TS] stringValue] forKey:P0_WINDOW_LOCK_TS];
    [res setValue:[[userDefaults valueForKey:P1_WINDOW_LOCK_TS] stringValue] forKey:P1_WINDOW_LOCK_TS];
    [res setValue:[[userDefaults valueForKey:P2_WINDOW_LOCK_TS] stringValue] forKey:P2_WINDOW_LOCK_TS];
    //total revenues
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_BY_CURRENCY] forKey:TOTAL_REVENUE_BY_CURRENCY];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_BY_CURRNECY] forKey:TOTAL_ADMON_REVENUE_BY_CURRNECY];
    //revenue per window
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P0] forKey:TOTAL_REVENUE_P0];
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P1] forKey:TOTAL_REVENUE_P1];
    [res setValue:[userDefaults valueForKey:TOTAL_REVENUE_P2] forKey:TOTAL_REVENUE_P2];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P0] forKey:TOTAL_ADMON_REVENUE_P0];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P1] forKey:TOTAL_ADMON_REVENUE_P1];
    [res setValue:[userDefaults valueForKey:TOTAL_ADMON_REVENUE_P2] forKey:TOTAL_ADMON_REVENUE_P2];
    //skan TS
    [res setValue:[[userDefaults valueForKey:LAST_SKAN_CALL_TIMESTAMP] stringValue] forKey:LAST_SKAN_CALL_TIMESTAMP];
    [res setValue:[[userDefaults valueForKey:FIRST_SKAN_CALL_TIMESTAMP] stringValue] forKey:FIRST_SKAN_CALL_TIMESTAMP];
    
    return res;
}


#pragma mark - internal
+ (BOOL)validateValues:(NSNumber *)conversionValue coarse:(NSNumber *)coarseValue{
    if ([conversionValue intValue] < 0 || 63 < [conversionValue intValue]) {
        return NO;
    }
    
    if (coarseValue) {
        if ([coarseValue intValue] > 2 || [coarseValue intValue] < 0) {
            return NO;
        }
    }
    
    return YES;
}

+ (NSURLComponents *)prepareQueryParams:(NSString *)bundleIdentifier eventName:(NSString *)eventName {
    NSURLComponents *components = [NSURLComponents componentsWithString:SINGULAR_API_URL];
    
    NSString *API_KEY = @"YOUR API KEY";
    NSString *APP_VERSION = @"YOUR APP VERSION";
    NSString *IDFV = @"IDFV";
    NSString *IDFA = @"IDFA";
    
    NSMutableArray *queryItems = [@[
        [NSURLQueryItem queryItemWithName:@"a" value:API_KEY],
        [NSURLQueryItem queryItemWithName:@"v" value:[[UIDevice currentDevice] systemVersion]],
        [NSURLQueryItem queryItemWithName:@"i" value:bundleIdentifier],
        [NSURLQueryItem queryItemWithName:@"app_v" value:APP_VERSION],
        [NSURLQueryItem queryItemWithName:@"n" value:eventName],
        [NSURLQueryItem queryItemWithName:@"p" value:@"iOS"],
        [NSURLQueryItem queryItemWithName:@"idfv" value:IDFV],
        [NSURLQueryItem queryItemWithName:@"idfa" value:IDFA]
    ] mutableCopy];
    
    NSDictionary *skanValues = [SKANSnippet getSkanDetails];
    [skanValues enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        if ([obj isKindOfClass:[NSDictionary class]]) {
            [queryItems addObject:[NSURLQueryItem queryItemWithName:key value:[SKANSnippet dictionaryToJsonString:obj]]];
        } else {
            [queryItems addObject:[NSURLQueryItem queryItemWithName:key value:obj]];
        }
    }];
    
    components.queryItems = queryItems;
    
    return components;
}

+ (void)getConversionValueFromServer:(NSString*)eventName withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError*))handler {
    if (!lockObject) {
        lockObject = [NSLock new];
    }
    
    @try {
        // 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];
            NSString *bundleIdentifier = @"YOUR BUNDLE IDENTIFIER";
            NSURLComponents *components = [SKANSnippet prepareQueryParams:bundleIdentifier eventName:eventName];
            
            [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                         completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
                if (error) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil, nil, NO, error);
                    }
                    
                    return;
                }
                
                NSDictionary *parsedResponse = [SKANSnippet jsonDataToDictionary:data];
                
                if (!parsedResponse) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Failed parsing server response"}]);
                    }
                    
                    return;
                }
                
                NSNumber *conversionValue = [parsedResponse objectForKey:SKAN_UPDATED_CONVERSION_VALUE];
                NSNumber *coarseValue = [parsedResponse objectForKey:SKAN_UPDATED_COARSE_VALUE];
                BOOL lockWindow = [[parsedResponse objectForKey:SKAN_UPDATED_LOCK_WINDOW_VALUE] boolValue];
                
                
                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(nil, nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                      code:0
                                                                  userInfo:@{NSLocalizedDescriptionKey:reason}]);
                        }
                    }
                    
                    return;
                }
                
                if(![SKANSnippet validateValues:conversionValue coarse:coarseValue]) {
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Illegal values recieved"}]);
                    }
                    
                    return;
                }
                
                if (![SKANSnippet getFirstSkanCallTimestamp]) {
                    [SKANSnippet setFirstSkanCallTimestamp];
                }
                
                [SKANSnippet setConversionValues:conversionValue coarseValue:coarseValue lockWindow:lockWindow handler:handler];
                
                [lockObject unlock];
            }] resume];
        });
    } @catch (id exception) {
        NSLog(@"%@", exception);
    }
}

+ (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];
}

+ (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];
}

+ (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];
}

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

+ (void)setConversionValues:(NSNumber *)conversionValue coarseValue:(NSNumber *)coarse lockWindow:(BOOL)lockWindow handler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError*))handler {
    @try {
        __block void(^skanResultHandler)(NSError * _Nullable error) = ^(NSError * _Nullable error) {
            if (handler) {
                if (error) {
                    handler(nil, nil, NO, error);
                } else {
                    handler(conversionValue, coarse, lockWindow, nil);
                }
            }
            
            [SKANSnippet valuesHasBeenUpdated:conversionValue coarseValue:coarse lockWindow:lockWindow];
        };
        
        if (@available(iOS 16.1, *)) {
            [SKAdNetwork updatePostbackConversionValue:[conversionValue integerValue] coarseValue:[SKANSnippet resolveCoarseValueFrom:coarse] lockWindow:lockWindow completionHandler:^(NSError * _Nullable error) {
                skanResultHandler(error);
            }];
        } else {
            if (@available(iOS 15.4, *)) {
                [SKAdNetwork updatePostbackConversionValue:[conversionValue integerValue] completionHandler:^(NSError * _Nullable error) {
                    skanResultHandler(error);
                }];
            }
        }
    } @catch (id exception) {
        NSLog(@"%@", exception);
    }
}

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

+ (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;
}

+ (NSInteger)getCurrentSkanWindow {
    NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getFirstSkanCallTimestamp];
    if (timeDiff < firstSkan4WindowInSec) { return 0; }
    if (timeDiff < secondSkan4WindowInSec) { return 1; }
    if (timeDiff < thirdSkan4WindowInSec) { return 2; }
    
    return -1;
}

// persist updated conversion values based on the active skan window.
+ (void)valuesHasBeenUpdated:(NSNumber *)fineValue coarseValue:(NSNumber *)coarseValue lockWindow:(BOOL)lockWindow {
    NSNumber *currentPersistedFineValue;
    NSNumber *currentPersistedCoarseValue;
    NSInteger window = [SKANSnippet getCurrentSkanWindow];
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
        
    switch (window) {
    case 0:
        currentPersistedFineValue = [userDefaults objectForKey:CONVERSION_VALUE_KEY];
        currentPersistedCoarseValue = [userDefaults objectForKey:P0_COARSE];
        [userDefaults setValue:fineValue forKey:CONVERSION_VALUE_KEY];
        [userDefaults setValue:currentPersistedFineValue forKey:P0_PREV_FINE_VALUE];
        [userDefaults setValue:coarseValue forKey:P0_COARSE];
        [userDefaults setValue:currentPersistedCoarseValue forKey:P0_PREV_COARSE_VALUE];
            
        if (lockWindow) {
            [userDefaults setObject:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P0_WINDOW_LOCK_TS];
        }
            
        break;
    
    case 1:
        currentPersistedCoarseValue = [userDefaults objectForKey:P1_COARSE];
        [userDefaults setValue:coarseValue forKey:P1_COARSE];
        [userDefaults setValue:currentPersistedCoarseValue forKey:P1_PREV_COARSE_VALUE];
            
        if (lockWindow) {
            [userDefaults setObject:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P1_WINDOW_LOCK_TS];
        }
            
        break;
    
    case 2:
        currentPersistedCoarseValue = [userDefaults objectForKey:P2_COARSE];
        [userDefaults setValue:coarseValue forKey:P2_COARSE];
        [userDefaults setValue:currentPersistedCoarseValue forKey:P2_PREV_COARSE_VALUE];
            
        if (lockWindow) {
            [userDefaults setValue:@([SKANSnippet getCurrentUnixTimestamp]) forKey:P2_WINDOW_LOCK_TS];
        }
            
        break;
    }
    
    [SKANSnippet setLastSkanCallTimestamp];
}

+ (BOOL)isSkanWindowOver {
    NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getFirstSkanCallTimestamp];
    return thirdSkan4WindowInSec <= timeDiff;
}

// Revenues are being accumulated and saved by Ad monetization and non ad monetization events, total sum and break down by skan windows.
+ (void)addToTotalRevenue:(NSNumber *)newRevenue withCurrency:(NSString *)currency isAdmon:(BOOL)isAdmon  {
    NSString *key = isAdmon ? TOTAL_ADMON_REVENUE_BY_CURRNECY : TOTAL_REVENUE_BY_CURRENCY;
    [SKANSnippet addToTotalRevenue:newRevenue withCurrency:currency forKey:key];
        
    NSInteger window = [SKANSnippet getCurrentSkanWindow];
    switch (window) {
        case 0:
            key = isAdmon ? TOTAL_ADMON_REVENUE_P0 : TOTAL_REVENUE_P0 ;
            break;
        case 1:
            key = isAdmon ? TOTAL_ADMON_REVENUE_P1 : TOTAL_REVENUE_P1 ;
            break;
            
        case 2:
            key = isAdmon ? TOTAL_ADMON_REVENUE_P2 : TOTAL_REVENUE_P2 ;
            break;
        case -1:
            key = nil;
            return;
    }
    
    [SKANSnippet addToTotalRevenue:newRevenue withCurrency:currency forKey:key];
}

// Coarse value is being sent on requests and responses as an Int and being translated into the system defined coarse value upon API execution.
+ (NSString *)resolveCoarseValueFrom:(NSNumber *)value {
    if(@available(iOS 16.1, *)) {
        if (!value) {
            return nil;
        }
        
        switch ([value integerValue]) {
            case 0:
                return SKAdNetworkCoarseConversionValueLow;
            case 1:
                return SKAdNetworkCoarseConversionValueMedium;
            case 2:
                return SKAdNetworkCoarseConversionValueHigh;
            default:
                return nil;
        }
    }
    
    return nil;
}

+ (NSDictionary*)getTotalRevenue:(NSString *)revenueKey {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    
    if (![userDefaults objectForKey:revenueKey]){
        [userDefaults setObject:[NSDictionary dictionary] forKey:revenueKey];
    }
    
    return [userDefaults objectForKey:revenueKey];
}

+ (void)addToTotalRevenue:(NSNumber *)newRevenue withCurrency:(NSString *)currency forKey:(NSString *)revenueKey {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    NSMutableDictionary *revenues = [[SKANSnippet getTotalRevenue:revenueKey] mutableCopy];
    NSNumber *currentRevenue = 0;

    if ([revenues objectForKey:currency]) {
        currentRevenue = [revenues objectForKey:currency];
    }

    currentRevenue = @([currentRevenue floatValue] + [newRevenue floatValue]);
    [revenues setObject:currentRevenue forKey:currency];
    [userDefaults setObject:revenues forKey:revenueKey];
    [userDefaults synchronize];
}

@end

S2S Integration Update (Recommended)

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_current_conversion_value: The latest fine conversion value
  • prev_fine_value: The previous fine conversion value
  • skan_first_call_to_skadnetwork_timestamp: Unix timestamp of the first call to the underlying SkAdNetwork API
  • skan_last_call_to_skadnetwork_timestamp: Unix timestamp of the latest call to the underlying SkAdNetwork API
  • p0/1/2_coarse: The latest coarse values per window
  • p0/1/2_prev_coarse_value:  The previous coarse values per window
  • p0/1/2_window_lock: Unix timestamp of the last update with window lock per window
  • p0/1/2_total_iap_revenue: Total of non Ad monetization revenue per window
  • p0/1/2_total_admon_revenue: Total of Ad monetization revenue per window
  • skan_total_revenue_by_currency: Total of non Ad monetization revenue
  • skan_total_admon_revenue_by_currency: Total of Ad monetization revenue

The following function shows how to extract these values:

NSDictionary *values = [SKANSnippet getSkanDetails];

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

// Only On app first launch
[SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //to Singular launch EP // After each session is handled [SKANSnippet updateConversionValuesAsync:handler]; NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //to Singular launch EP
// After handling non-revenue events [SKANSnippet updateConversionValuesAsync:@"event_name"
withCompletionHandler:handler]; NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"] //to Singular evt EP
// After handling revenue events [SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"]; [SKANSnippet updateConversionValuesAsync:@"revenue_event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"] //to Singular evt EP