Server-to-Server - SKAdNetwork 4 Implementation Guide

SKAdNetwork 4.0 Implementation Guide

Implement Apple's SKAdNetwork 4.0 framework for privacy-focused iOS attribution using server-to-server integration, enabling measurement of app install campaigns with enhanced postback windows and coarse conversion values while protecting user privacy.


Overview

What is SKAdNetwork 4.0?

SKAdNetwork (SKAN) is Apple's privacy-focused attribution framework enabling measurement of iOS app install advertising campaigns while protecting user privacy through server-to-server implementation for robust validation and tracking.

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


Key Features

SKAdNetwork 4.0 introduces enhanced measurement capabilities and flexibility for campaign optimization.

  • Robust Validation: Aggregation of postbacks from all networks with built-in fraud protection
  • Dynamic Conversion Management: Dashboard configuration for conversion value encoding
  • Enhanced Reporting: Enriched marketing parameters and granular data insights
  • Secure Partner Postbacks: Decoded conversion values and revenue tracking
  • Comprehensive Validation: Enriched event and session tracking for implementation verification
  • Multiple Postback Windows: Automated timestamp management across 0-2 days, 3-7 days, 8-35 days
  • Revenue Tracking: Support for ad monetization and regular revenue with currency specifications

Prerequisites

Familiarize yourself with SKAdNetwork concepts and previous versions before implementing SKAN 4.0.


Singular SKAdNetwork Solution

Singular's SKAdNetwork solution provides end-to-end attribution management from client-side implementation to postback processing and campaign optimization.

Solution Components

Platform Features

Comprehensive SKAdNetwork support across the attribution and analytics workflow.

Component Functionality
Client-Side Code Native iOS code samples for SKAdNetwork framework registration and conversion value management. Alternative server-side approach available using Conversion Value API endpoint
Postback Processing Validation and aggregation of postbacks from all ad networks with unified reporting
Fraud Protection Cryptographic signature validation, transaction ID deduplication, and secure parameter verification for unsigned data
Conversion Management Dynamic dashboard configuration for encoding post-install activities into conversion values
Reporting Campaign ID translation and enrichment with marketing parameters for granular analysis
Partner Postbacks Decoded conversion values sent as events and revenue for partner optimization

Implementation Architecture

Two-Part Implementation

Client-side SKAdNetwork implementation consists of two main components.

1. Client-Side Implementation (Required):

  • SKAdNetwork framework registration on app launch
  • Intelligent conversion value management based on post-install activity
  • Essential for campaign optimization using SKAdNetwork attribution
  • Enables tracking of associated post-install activities

2. Server-Side Integration Update (Recommended):

  • Validates and troubleshoots client-side implementation
  • Enriches events and sessions sent via Session and Event endpoints
  • Enables SKAdNetwork metadata validation
  • Confirms proper app-side implementation

Client-Side Implementation

Implement SKAdNetwork framework registration and conversion value management using Singular's native iOS code samples for optimal campaign measurement with SKAN 4.0 features.

Implementation Responsibilities

Core Functionality

Code samples support SKAdNetwork registration and intelligent conversion value management.

  1. SKAdNetwork Registration: Registers app with framework immediately after launch to enable attribution
  2. Conversion Value Management:
    • Communicates synchronously with Singular endpoint to receive next conversion value
    • Reports sessions, events, and revenue to Singular
    • Receives encoded conversion value representing configured post-install activity
    • Collects SKAdNetwork metadata by measurement period for validation and calculation

Metadata Collection

Code collects essential SKAdNetwork metadata for both validation and conversion value calculation.

  • First call timestamp to underlying SKAdNetwork framework
  • Last call timestamp to underlying SKAdNetwork framework
  • Last updated postback values (both Coarse and Fine)
  • Total Revenue and Total Ad Monetization Revenue generated by device

Integration Flow

End-to-End Process

Complete SKAdNetwork flow for S2S customers from client-side conversion management to postback processing.

SKAdNetwork 4.0 S2S Integration Flow

  1. Conversion Value Request: App code communicates with Singular endpoint synchronously to get latest conversion value based on sessions, events, and revenue
  2. Framework Update: App updates SKAdNetwork framework with received conversion value
  3. Metadata Enrichment: App enriches S2S events and sessions with SKAdNetwork metadata for validation
  4. Timer Expiration: After timer expires, SKAdNetwork sends postback to ad network
  5. Postback Forwarding: Network forwards postback to Singular (secure setup or regular)
  6. Postback Processing: Singular processes postback by:
    • Validating cryptographic signature
    • Decoding conversion value using configured model
    • Enriching with network information from partner integrations
    • Sending decoded data to BI and partners via postbacks

Data Separation: SKAdNetwork data (installs and decoded events) accessible via separate reports, APIs, ETL tables, and postbacks to prevent mixing with existing datasets during testing and validation.


SKAdNetwork Interface

Complete interface definition for SKAdNetwork integration providing methods for attribution tracking, conversion value updates, and revenue management.

Method Definitions

Attribution Registration

Initializes SKAN attribution tracking on first app launch, setting initial conversion value to 0 and establishing baseline timestamps.

Objective-C
+ (void)registerAppForAdNetworkAttribution;

Conversion Value Management

Methods update conversion values based on post-install activity captured by app and selected conversion model configured dynamically.

Supported Activities:

  • Sessions: Critical for retention measurement
  • Conversion Events: Critical for post-install event measurement
  • Revenue Events: Critical for revenue measurement

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. Must be called before conversion value updates.

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

Data Retrieval

Returns comprehensive SKAN data dictionary including conversion values, timestamps, and revenue tracking.

Dictionary Contains:

  • 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

Complete Interface Code

SKANSnippet.h

Objective-C
//SKANSnippet.h

#import <Foundation/Foundation.h>

@interface SKANSnippet : NSObject

// Register for SKAdNetwork attribution.
// Call this method as soon as possible on first app launch.
// Sets conversion value to 0 and updates timestamp for additional processing.
+ (void)registerAppForAdNetworkAttribution;

// Track retention and cohorts by calling for each app open.
// Reports session details and updates conversion value if needed.
// Optional callback runs once conversion value is updated.
+ (void)updateConversionValuesAsync:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// Track conversion events by calling after each event and before sending to Singular.
// Reports event details and updates conversion value if needed.
// Optional callback runs once conversion value is updated.
+ (void)updateConversionValuesAsync:(NSString *)eventName 
                withCompletionHandler:(void(^)(NSNumber *, NSNumber *, BOOL, NSError *))handler;

// Track revenue by calling before every revenue event.
// Updates total revenue for next conversion value calculation.
// Note:
// 1. Call before 'updateConversionValuesAsync' to ensure revenue included
// 2. Avoid calling on event retries to prevent double-counting
+ (void)updateRevenue:(double)amount 
          andCurrency:(NSString *)currency 
     isAdMonetization:(BOOL)admon;

// Gets current fine, coarse, window locked values saved in dictionary.
// Contains all relevant SKAN values including:
// - skan_current_conversion_value
// - prev_fine_value  
// - skan_first_call_to_skadnetwork_timestamp
// - skan_last_call_to_skadnetwork_timestamp
// - skan_total_revenue_by_currency
// - skan_total_admon_revenue_by_currency
// - p0_coarse, p1_coarse, p2_coarse
// - p0_window_lock, p1_window_lock, p2_window_lock
// - Previous coarse values and revenue per window
+ (NSDictionary *)getSkanDetails;

@end

SKAdNetwork Implementation

Complete implementation code for Apple's SKAdNetwork 4.0 interface, managing attribution tracking, conversion values, and revenue reporting across multiple postback windows.

Implementation Overview

Constants and Configuration

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 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 mapped to 0-2)
  • Revenue tracking by currency for each postback window
  • Timestamp management for postback windows and lock states
  • 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

Complete Implementation Code

SKANSnippet.m

Important: Replace placeholder values (YOUR API KEY, YOUR APP VERSION, etc.) with actual values from your application before production use.

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_CURRENCY @"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_CURRENCY] forKey:TOTAL_ADMON_REVENUE_BY_CURRENCY];
    //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]) {
                    [lockObject unlock];
                    if (handler) {
                        handler(nil,nil, NO, [NSError errorWithDomain:bundleIdentifier
                                                                 code:0
                                                             userInfo:@{NSLocalizedDescriptionKey:@"Illegal values received"}]);
                    }
                    
                    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];
    
}

+ (void)setLastSkanCallTimestamp {
    NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
    [userDefaults setInteger:[SKANSnippet getCurrentUnixTimestamp] forKey:LAST_SKAN_CALL_TIMESTAMP];
    
}

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

@end

S2S Integration Update

Enhance server-to-server integration with SKAdNetwork metadata for implementation validation and troubleshooting (recommended for all implementations).

Metadata Structure

Data Retrieval

Use getSkanDetails method to extract metadata dictionary and forward to server for appending as query parameters on Session and Event endpoint API requests.

Critical: Metadata should be forwarded on every session and every event reported to Singular via Session Notification Endpoint and Event Notification Endpoint.

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

// Forward skanMetadata to your server for S2S API enrichment

App Lifecycle Implementation

Integrate SKAdNetwork methods at appropriate app lifecycle points for complete attribution coverage with SKAN 4.0 features.

Implementation Examples

Implementation Notes:

  • Uses asynchronous methods for conversion value updates to prevent blocking main thread
  • All SKAN-related data collected in dictionary format before server transmission
  • Follows Apple's privacy-first approach while enabling essential attribution tracking
  • Revenue tracking includes monetary value and currency specification for accurate reporting

App First Launch

Registers app with SKAdNetwork for attribution tracking and sends initial session data to Singular's endpoint. Only runs on first app launch to establish attribution tracking.

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

Session Management

Updates conversion values after each session and sends updated SKAN details to track user engagement.

Objective-C
[SKANSnippet updateConversionValuesAsync:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Conversion value update failed: %@", error);
    } else {
        NSLog(@"Values updated - Fine: %@, Coarse: %@, Lock: %d", fine, coarse, lock);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendSessionToServer:skanValues];

Event Tracking

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

Objective-C
[SKANSnippet updateConversionValuesAsync:@"event_name" 
                   withCompletionHandler:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Event conversion update failed: %@", error);
    } else {
        NSLog(@"Event values updated - Fine: %@, Coarse: %@", fine, coarse);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"event_name"];

Revenue Tracking

Manages revenue events by updating revenue amount with currency and associated conversion values, then sending to Singular's event endpoint for purchase-related activities.

Objective-C
[SKANSnippet updateRevenue:15.53 andCurrency:@"USD" isAdMonetization:NO];
[SKANSnippet updateConversionValuesAsync:@"revenue_event_name" 
                   withCompletionHandler:^(NSNumber *fine, NSNumber *coarse, BOOL lock, NSError *error) {
    if (error) {
        NSLog(@"Revenue conversion update failed: %@", error);
    } else {
        NSLog(@"Revenue values updated - Fine: %@, Coarse: %@", fine, coarse);
    }
}];

NSDictionary *skanValues = [SKANSnippet getSkanDetails];
[self sendEventToServer:skanValues eventName:@"revenue_event_name"];

Conversion Value API

Alternative server-side approach for reporting SKAdNetwork conversion values using REST API endpoint instead of client-side interface implementation.

API Overview

Implementation Methods

SKAdNetwork conversion values can be reported through two methods with identical data flow and reporting integrity.

  1. Direct SKAdNetwork Interface: Client-side implementation (see above)
  2. Server-Side Integration: Using Conversion Value API Endpoint

Conversion Value API Endpoint accepts identical parameters as client-side interface, ensuring consistent attribution tracking with flexibility to choose implementation best fitting technical architecture.


API Endpoint

HTTP Method and URL

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

Required Parameters

API Key

Parameter Description
a Singular SDK Key from Developer Tools. Do not use reporting API Key.

Example: sdkKey_afdadsf7asf56

Device Identifiers

Parameter Description
idfa Identifier for Advertisers (IDFA) for ad tracking and attribution. Starting iOS 14.5, requires ATT framework opt-in. Omit if unavailable (do not pass NULL or empty string).

Example:DFC5A647-9043-4699-B2A5-76F03A97064B
idfv Identifier for Vendors (IDFV) - required on all requests regardless of ATT status. Unique per vendor/developer across their app ecosystem.

Example:21DB6612-09B3-4ECC-84AC-B353B0AF1334

Device Parameters

Parameter Description
p Platform of app (must be "iOS" for this API).

Example: iOS
v OS Version of device at session time.

Example: 16.1

Application Parameters

Parameter Description
i App Identifier (Bundle ID for iOS application, case-sensitive).

Example: com.singular.app
app_v Application Version.

Example: 1.2.3

Event Parameters

Parameter Description
n Event name being tracked (max 32 ASCII characters). For sessions use __SESSION__. For events, use same name and casing sent to Singular via Event API.

Example: sng_add_to_cart

Conversion Value Parameters

Parameter Description
skan_current_conversion_value
iOS 15.4+
Latest SKAdNetwork conversion value at time of previous session/event (0-63).

Example: 7
p1_coarse
iOS 16.1+
Latest SKAdNetwork coarse conversion value for postback_sequence 1 (0-2).

Example: 0
p2_coarse
iOS 16.1+
Latest SKAdNetwork coarse conversion value for postback_sequence 2 (0-2).

Example: 1

Revenue Tracking Parameters

Parameter Description
skan_total_revenue_by_currency
iOS 15.4+
Required for IAP or All Revenue models. Current aggregated total of IAP revenue (excluding ad monetization), JSON URL-encoded string.

Example: %7B%22USD%22%3A9.99%7D
skan_total_admon_revenue_by_currency
iOS 15.4+
Required for Admon or All Revenue models. Current aggregated total of ad monetization revenue, JSON URL-encoded string.

Example: %7B%22USD%22%3A1.2%7D

Timestamp Parameters

Parameter Description
skan_first_call_to_skadnetwork_timestamp
iOS 15.4+
Unix timestamp of first call to underlying SKAdNetwork API.

Example: 1483228800
skan_last_call_to_skadnetwork_timestamp
iOS 15.4+
Unix timestamp of latest call to underlying SKAdNetwork API at time of this session notification.

Example: 1483228800

Request Examples

Sample Implementations

Code samples demonstrate core required parameters. When implementing, include all required parameters and validate correct values before production use.

PythoncURLHTTP
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())

Response Format

Successful Response

HTTP 200 - ok response without error or reason indicates request sent to queue for processing.

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

Response Parameters

Key Description Example
conversion_value New fine conversion value 0-63
skan_updated_coarse_value New coarse conversion value 0-2
postback_sequence_index SKAN postback measurement period (0=postback 1, 1=postback 2, 2=postback 3). Indicates which coarse value key to update 0-2
status Processing status ok

Possible Errors

  • More than 24 hours past since last conversion update (28032 hours), update window closed
  • Unknown platform error - Non iOS platform
  • Conversion Management: Invalid parameter given
  • Conversion Management: Conversion Model not found for app
  • Invalid measurement period
  • Conversion Management: Cannot find currency of owner