Server-to-Server SKAdNetwork 4 Implementation Guide

SKAdNetwork 4 Implementation Guide

SKAdNetwork Use Case

SKAdNetwork (SKAN) is Apple's privacy-focused attribution framework that enables measurement of iOS app install advertising campaigns while protecting user privacy. The Server-to-Server (S2S) implementation provides a robust way to validate and track campaign performance by sending SKAdNetwork data directly between servers, ensuring accurate attribution and conversion tracking.

The framework handles all critical aspects of attribution while maintaining user privacy through Apple's prescribed methods, making it an essential tool for mobile marketers operating in the post-iOS 14.5 landscape.

Key Points

  • Robust validation and aggregation of postbacks from all networks with built-in fraud protection
  • Dynamic conversion value management through dashboard configuration
  • Enhanced reporting capabilities with enriched marketing parameters and granular data insights
  • Secure partner postback system for decoded conversion values and revenue tracking
  • Comprehensive validation of client-side implementation through enriched event and session tracking
  • Automated timestamp management across multiple postback windows (0-2 days, 3-7 days, 8-35 days)
  • Support for both ad monetization and regular revenue tracking with currency specifications

Prerequisites

Key Components

Singular's SKAdNetwork solution consists of the following components:

  • Client-side code to implement SKAdNetwork
    An alternative Server-side approach is available, using the conversion value API endpoint.
  • 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.

The Client-side 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. Server-side integration update: This part is important for validating and troubleshooting the client-side implementation. By enriching events and sessions sent to Singular via the Session and Event endpoints with SKAdNetwork metadata, Singular can validate that the implementation was done properly on your app side.

Getting Started


SKAdNetwork Client-Side Implementation

Singular provides SKAdNetwork Interface Code Snippets 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 Total Admon Revenue generated by the device

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 Interface

This header file defines the public interface for SKAdNetwork (SKAN) integration, providing methods for attribution tracking, conversion value updates, and revenue management in iOS apps.

SKANSnippet.h Code Sample

Attribution Registration

Initializes SKAN attribution tracking on first app launch, setting initial conversion value to 0 and establishing baseline timestamps. The underlying Apple API method generates a notification if the device has attribution data for that app.

Objective-C
+ (void)registerAppForAdNetworkAttribution;

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

Session Tracking

Manages session-based tracking for retention and cohort analysis, with optional completion handler for post-update actions.

Objective-C
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

Event Tracking

Handles conversion event tracking before sending data to Singular, updating conversion values based on event context.

Objective-C
+ (void)updateConversionValuesAsync:(NSString *)eventName 
                withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

Revenue Management

Tracks revenue events, maintaining separate totals for ad monetization and regular revenue, requiring calling before conversion value updates.

Objective-C
+ (void)updateRevenue:(double)amount 
          andCurrency:(NSString *)currency 
     isAdMonetization:(BOOL)admon;

Data Retrieval

Returns comprehensive SKAN data including:

  • Current and previous fine-grained conversion values
  • Coarse values across different postback windows
  • Window lock timestamps
  • Revenue tracking by currency
  • Separate tracking for ad monetization and regular revenue
Objective-C
+ (NSDictionary *)getSkanDetails;

Implementation Notes

  • Methods use asynchronous patterns to prevent blocking main thread
  • Revenue tracking must precede conversion value updates
  • Supports both fine-grained (0-63) and coarse (Low/Medium/High) conversion values
  • Maintains separate tracking for different postback windows
  • Implements comprehensive error handling through completion handlers

SKANSnippet.h Interface Code

Objective-C
//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

The code implements Apple's SKAdNetwork (SKAN) interface for iOS apps, managing attribution tracking, conversion values, and revenue reporting across multiple postback windows.

SKANSnippet.m Code Sample

Constants and Configuration

The implementation defines three distinct postback windows for tracking user activity and conversions.

Objective-C
static NSInteger firstSkan4WindowInSec = 3600 * 24 * 2;  // 48 hours
static NSInteger secondSkan4WindowInSec = 3600 * 24 * 7;  // 7 days
static NSInteger thirdSkan4WindowInSec = 3600 * 24 * 35; // 35 days

Key Features

  • Attribution Registration:Handles initial app attribution setup and first-time conversion value tracking
  • Conversion Value Management: Updates and tracks conversion values across multiple postback windows.
  • Revenue Tracking: Maintains separate tracking for ad monetization and regular revenue events.
  • Data Persistence: Uses NSUserDefaults to store SKAN-related data across app sessions.
  • Thread Safety: Implements NSLock for thread-safe operations during network calls.

Data Storage Structure

  • Fine-grained conversion values (0-63)
  • Coarse values (Low/Medium/High)
  • Revenue tracking by currency
  • Timestamp management for postback windows
  • Previous value tracking for both fine and coarse conversions

Privacy Considerations

  • Implements iOS 15.4+ and iOS 16.1+ specific features
  • Handles postback conversion value updates according to Apple's privacy guidelines
  • Maintains separate tracking for different revenue types to ensure accurate attribution

Technical Notes

  • Uses asynchronous operations for network calls and value updates
  • Implements error handling and validation for conversion values
  • Supports both traditional and coarse conversion value tracking
  • Manages multiple postback windows with different durations and requirements

S2S Integration Update (Recommended)

Update your S2S integration with the following SKAdNetwork metadata.

This metadata should be forwarded on every session and every event reported to Singular via the Session Notification Endppint and the Event Notification Endppoint. This data is used for the validation of SKAdNetwork implementation.

Metadata Structure

Conversion Values

Use the Data Retrieval method to extract the dictionary of metadata and forward it to your server to append as query parameters on the Session and Event endpoint API requests.

Objective-C
NSDictionary *values = [SKANSnippet getSkanDetails];

SKANSnippet.m Implementation Code

Objective-C
//  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

App Lifecycle Flow Example

This code demonstrates the key integration points for SKAdNetwork (SKAN) attribution in an iOS app lifecycle, handling app launches, sessions, events, and revenue tracking.

Implementation Notes

  • The code uses asynchronous methods for conversion value updates to prevent blocking the main thread
  • All SKAN-related data is collected in a dictionary format before being sent to the server
  • The implementation follows Apple's privacy-first approach while still allowing for essential attribution tracking
  • Revenue tracking includes both monetary value and currency specification for accurate financial reporting

App First Launch

This code registers the app with SKAdNetwork for attribution tracking and sends initial session data to Singular's endpoint. This only runs on the first app launch to establish attribution tracking.

This only runs on the first app launch to establish attribution tracking.

Objective-C
[SKANSnippet registerAppForAdNetworkAttribution];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues] //to Singular launch EP 

Session Management

This section updates conversion values after each session and sends the updated SKAN details to track user engagement.

Objective-C
[SKANSnippet updateConversionValuesAsync:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues]

Event Tracking

This code handles non-revenue events by updating conversion values and sending event data to Singular's event endpoint.

Objective-C
[SKANSnippet updateConversionValuesAsync:@"event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"]

Revenue Tracking

This section manages revenue events by updating both the revenue amount with currency and the associated conversion values. The data is then sent to Singular's event endpoint for tracking purchase-related activities.

Objective-C
[SKANSnippet updateRevenue:15.53 andCurrency:@"KRW"];
[SKANSnippet updateConversionValuesAsync:@"revenue_event_name" withCompletionHandler:handler];
NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"]

Conversion Value API

The SKAdNetwork conversion value can be reported through two methods:

  1. Direct SKAdNetwork Interface implementation on the client-side - see above
  2. Server-side integration using the Conversion Value API Endpoint

Both methods maintain the same data flow and reporting integrity, allowing you to choose the implementation that best fits your technical architecture. The Conversion Value API Endpoint accepts identical parameters as the client-side interface, ensuring consistent attribution tracking.

Conversion Value API Reference

Contents


Conversion Value API Endpoint

HTTP Method and Conversion Value Endpoint

GET https://sdk-api-v1.singular.net/api/v2/conversion_value

Required Parameters

The following table lists the required and optional parameters to support the Conversion API from your server. All of the parameters listed are query parameters.

Required Parameters
API Key
Parameter Description
a
string

The a parameter specifies the Singular SDK Key.

Retrieve the SDK Key from the Singular UI, under Developer Tools in the Main Menu.

Note: Do not use the reporting API Key as this will result in rejected data.

 

Example Value:
sdkKey_afdadsf7asf56
Device Identifier Parameters
Parameter Description
idfa
string

The idfa parameter specifies the Identifier for Advertisers (IDFA) which helps advertisers track and attribute user actions (e.g., ad clicks, app installs) to specific campaigns, enabling precise ad targeting and campaign optimization.

Starting with iOS 14.5, users must opt-in via the App Tracking Transparency (ATT) framework before apps can access the IDFA. If users do not opt-in to IDFA then the IDFA will be unavailable resulting in limiting tracking capabilities.

 

Example Value:
DFC5A647-9043-4699-B2A5-76F03A97064B
Parameter Description
idfv
string

The idfv parameter specifies the Identifier for Vendors (IDFV), a unique identifier assigned by Apple to a device, which is specific to a particular vendor or developer. It remains consistent across all apps from the same vendor on a given device, allowing the vendor to track user behavior and interactions across their app ecosystem without identifying the user personally.

 

Example Value:
21DB6612-09B3-4ECC-84AC-B353B0AF1334
Device Parameters
Parameter Description
p
string

The p parameter specifies the platform of the App. Since this API is only used for iOS, this value must be iOS.

 

Example Value:
iOS
Parameter Description
v
string

The v parameter specifies the OS Version of the device at session time.

 

Example Value:
16.1
Application Parameters
Parameter Description
i
string

The i parameter specifies the App Identifier.

This is the Bundle ID for the iOS application. (case-sensitive)

Example Value:
com.singular.app
Parameter Description
app_v
string

The app_v parameter specifies the Application Version.

 

Examples:
1.2.3
Event Parameters
Parameter Description
n
string

The n parameter specifies the Name of the event being tracked.

  • Limitation: max 32 ASCII characters
  • For Sessions, use the Event Name:
    __SESSION__
  • For non-session events, use the same Event Name and casing sent to Singular via the Event API Endpoint.

 

Example Value:
sng_add_to_cart
Conversion Value Parameters
Parameter Description
skan_current_conversion_value

Supported Platforms:

  • iOS 15.4+
int

The latest SKAdNetwork conversion value, at the time of the previous session/event notification. This is an integer between (0-63).

 

Example Value:

7
Parameter Description
p1_coarse

Supported Platforms:

  • iOS 16.1+
int

The latest SKAdNetwork coarse conversion value for postback_sequence 1, at the time of the previous session/event notification. This is an integer between (0-2).

 

Example Value:

0
Parameter Description
p2_coarse

Supported Platforms:

  • iOS 16.1+
int

The latest SKAdNetwork coarse conversion value for postback_sequence 2, at the time of the previous session/event notification. This is an integer between (0-2).

 

Example Value:

1
Revenue Tracking Parameters
Parameter Description
skan_total_revenue_by_currency

Supported Platforms:

  • iOS 15.4+
JSON URL-encoded string

Required if using IAP or All Revenue models. Current aggregated total of IAP revenue generated by the device, excluding any Ad monetization revenue.

{
   "USD":9.99
}

 

Example Value:

%7B%22USD%22%3A9.99%7D
Parameter Description
skan_total_admon_revenue_by_currency

Supported Platforms:

  • iOS 15.4+
JSON URL-encoded string

Required if using Admon or All Revenue Conversion Models. Current aggregated total of Ad monetization revenue generated by the device.

{
   "USD":1.2
}

 

Example Value:

%7B%22USD%22%3A5%7D
Timestamp Parameters
Parameter Description
skan_first_call_to_skadnetwork_timestamp

Supported Platforms:

  • iOS 15.4+
int

Unix timestamp of the first call to the underlying SKAdNetwork API.

 

Example Value:

1483228800
Parameter Description
skan_last_call_to_skadnetwork_timestamp

Supported Platforms:

  • iOS 15.4+
int

Unix timestamp of the latest call to the underlying SKAdNetwork API, at the time of this session notification.

 

Example Value:

1483228800

Request Body

Do not provide a request body when calling this method. The request must be sent using the GET method with query parameters.

 

Request Examples

The following code samples may not represent all supported parameters. When implementing the request be sure to include all required parameters as listed above, and validate that the correct values are being passed before sending data from a production instance. It is advised to uses a unique `i` parameter (application identifier) for development and testing.

 

PYTHON CURL HTTP JAVA

PYTHON

import requests

params = {
    'a': 'sdk_key_here',
    'p': 'iOS',
    'i': 'com.singular.app',
    'v': '16.1',
    'idfa': 'DFC5A647-9043-4699-B2A5-76F03A97064B',
    'idfv': '21DB6612-09B3-4ECC-84AC-B353B0AF1334',
    'n': '__SESSION__',
    'app_v': '1.2.3',
    'skan_current_conversion_value': 7,
    'p1_coarse': 0,
    'p2_coarse': 1,
    'skan_total_revenue_by_currency': {"USD":9.99},
    'skan_total_admon_revenue_by_currency': {"USD":1.2},
    'skan_first_call_to_skadnetwork_timestamp': 1510090877,
    'skan_last_call_to_skadnetwork_timestamp': 1510090877
}

response = requests.get('https://sdk-api-v1.singular.net/api/v2/conversion_value', params=params)
print(response.json())

 

Request Response

The following identifies a successful API response with a new conversion value returned.

HTTP Response
200 - ok

The 200 - ok without any error or reason in the response body means the request was sent to the queue for processing.

 

Response:
{
   "conversion_value":1,
   "skan_updated_coarse_value":0,
   "postback_sequence_index":0,
   "status":"ok"
}

Response Parameters

The following table defines the response parameters.

Key Description Example Value
conversion_value

New fine conversion value

0-63
skan_updated_coarse_value

New coarse conversion value

0-2
postback_sequence_index

Corresponds to the SKAN postback measurement periods:

  • 0 = postback 1
  • 1 = postback 2
  • 2 = postback 3

Indicates which coarse conversion value key should be updated: i.e. p0_coarse, p1_coarse, p2_coarse

0-2
status

Ok for successfully processed

ok

Possible Response Errors

  • More than 24 hours have past since last conversion update (28032 hours), the update window is closed:
    • Hours calculated as:
      (skan_last_call_to_skadnetwork_timestamp) - (skan_first_call_to_skadnetwork_timestamp)
  • Unknown platform error - Non iOS platform
  • Conversion Management: Invalid parameter %x given
  • Conversion Management: Conversion Model not found for app: %x
  • Invalid measurement period: %x
  • Conversion Management: Cannot find currency of owner: %customer

Optional Parameters

The following table lists the optional parameters used to support SKAdNetwork version 4. All of the parameters listed are query parameters.

Optional Parameters
Conversion Value Parameters
Parameter Description
p0_coarse

Supported Platforms:

  • iOS 16.1+
int

Not required. This key is mapped from the skan_current_conversion_value key. In other words, the model does not use p0_coarse when evaluating the skan_updated_coarse_value to return on the response - it uses the skan_current_conversion_value. This is an integer between (0-2).

 

Example Value:

0
Parameter Description
p0_prev_coarse_value

Supported Platforms:

  • iOS 16.1+
int

The previous coarse value for p0. This is an integer between (0-2).

 

Example Value:

0
Parameter Description
p1_prev_coarse_value

Supported Platforms:

  • iOS 16.1+
int

The previous coarse value for p1. This is an integer between (0-2).

 

Example Value:

0
p2_prev_coarse_value

Supported Platforms:

  • iOS 16.1+
int

The previous coarse value for p2. This is an integer between (0-2).

 

Example Value:

0
Revenue Tracking
p0_total_iap_revenue

Supported Platforms:

  • iOS 16.1+
JSON URL-encoded string

Total of IAP Revenue for p0 excluding Ad monetization revenue.

{
   "USD":9.99
}

 

Example Value:

%7B%22USD%22%3A9.99%7D
p1_total_iap_revenue

Supported Platforms:

  • iOS 16.1+
JSON URL-encoded string

Total of IAP Revenue for p1 excluding Ad monetization revenue.

{
   "USD":9.99
}

 

Example Value:

%7B%22USD%22%3A9.99%7D
p2_total_iap_revenue

Supported Platforms:

  • iOS 16.1+
JSON URL-encoded string

Total of IAP Revenue for p2 excluding Ad monetization revenue.

{
   "USD":9.99
}

 

Example Value:

%7B%22USD%22%3A9.99%7D
p0_total_admon_revenue

Supported Platforms:

  • iOS 16.1+
JSON URL-encoded string

Total of Ad monetization revenue for p0.

{
   "USD":1.2
}

 

Example Value:

%7B%22USD%22%3A1.2%7D
p1_total_admon_revenue

Supported Platforms:

  • iOS 16.1+
JSON URL-encoded string

Total of Ad monetization revenue for p1.

{
   "USD":1.2
}

 

Example Value:

%7B%22USD%22%3A1.2%7D
p2_total_admon_revenue

Supported Platforms:

  • iOS 16.1+
JSON URL-encoded string

Total of Ad monetization revenue for p2.

{
   "USD":1.2
}

 

Example Value:

%7B%22USD%22%3A1.2%7D
Timestamp Parameters
Parameter Description
p0_window_lock

Supported Platforms:

  • iOS 16.1+
int

Unix timestamp of the last update with window lock for p0

Note - the singular conversion model does not currently account for window lock, but may eventually

 

Example Value:

1483228850
Parameter Description
p1_window_lock

Supported Platforms:

  • iOS 16.1+
int

Unix timestamp of the last update with window lock for p1

Note - the singular conversion model does not currently account for window lock, but may eventually

 

Example Value:

1483228850
Parameter Description
p2_window_lock

Supported Platforms:

  • iOS 16.1+
int

Unix timestamp of the last update with window lock for p2

Note - the singular conversion model does not currently account for window lock, but may eventually

 

Example Value:

1483228850