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
- Learn about SKAdNetwork 4.0 in the SKAN 4.0 FAQ.
- For more background about SKAdNetwork, see the SKAdNetwork 3.0 S2S Implementation Guide.
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:
- 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.
- 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:
- SKAdNetwork support and registration
- 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
The diagram above illustrates the SKAdNetwork flow for an S2S customer:
- 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.
- Second, the app enriches existing events and sessions with SKAdNetwork data, which will be later used for validations.
- 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.
- The network will forward it to Singular (either via the secure setup or regular setup).
- 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.
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.
+ (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.
+ (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.
+ (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.
+ (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
+ (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
//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.
Constants and Configuration
The implementation defines three distinct postback windows for tracking user activity and conversions.
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.
NSDictionary *values = [SKANSnippet getSkanDetails];
SKANSnippet.m Implementation Code
// 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.
[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.
[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.
[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.
[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:
- Direct SKAdNetwork Interface implementation on the client-side - see above
- 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.
Contents
- Conversion Value API Endpoint
- Required Parameters
- Request Body
- Request Examples
- Request Response
- Response Parameters
- Possible Response Errors
- Optional Parameters
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 |
|
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:
|
Device Identifier Parameters | |
Parameter | Description |
|
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:
|
Parameter | Description |
|
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:
|
Device Parameters | |
Parameter | Description |
|
The p parameter specifies the platform of the App. Since this API is only used for iOS, this value must be iOS. Example Value:
|
Parameter | Description |
|
The v parameter specifies the OS Version of the device at session time. Example Value:
|
Application Parameters | |
Parameter | Description |
|
The i parameter specifies the App Identifier. This is the Bundle ID for the iOS application. (case-sensitive) Example Value:
|
Parameter | Description |
|
The app_v parameter specifies the Application Version. Examples:
|
Event Parameters | |
Parameter | Description |
|
The n parameter specifies the Name of the event being tracked.
Example Value:
|
Conversion Value Parameters | |
Parameter | Description |
Supported Platforms:
|
The latest SKAdNetwork conversion value, at the time of the previous session/event notification. This is an integer between (0-63).
Example Value:
|
Parameter | Description |
Supported Platforms:
|
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:
|
Parameter | Description |
Supported Platforms:
|
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:
|
Revenue Tracking Parameters | |
Parameter | Description |
Supported Platforms:
|
Required if using IAP or All Revenue models. Current aggregated total of IAP revenue generated by the device, excluding any Ad monetization revenue.
Example Value:
|
Parameter | Description |
Supported Platforms:
|
Required if using Admon or All Revenue Conversion Models. Current aggregated total of Ad monetization revenue generated by the device.
Example Value:
|
Timestamp Parameters | |
Parameter | Description |
Supported Platforms:
|
Unix timestamp of the first call to the underlying SKAdNetwork API.
Example Value:
|
Parameter | Description |
Supported Platforms:
|
Unix timestamp of the latest call to the underlying SKAdNetwork API, at the time of this session notification.
Example Value:
|
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
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())
CURL
curl -G 'https://sdk-api-v1.singular.net/api/v2/conversion_value' \
--data-urlencode 'a=sdk_key_here' \
--data-urlencode 'p=iOS' \
--data-urlencode 'i=com.singular.app' \
--data-urlencode 'v=16.1' \
--data-urlencode 'idfa=DFC5A647-9043-4699-B2A5-76F03A97064B' \
--data-urlencode 'idfv=21DB6612-09B3-4ECC-84AC-B353B0AF1334' \
--data-urlencode 'n=__SESSION__' \
--data-urlencode 'app_v=1.2.3' \
--data-urlencode 'skan_current_conversion_value=7' \
--data-urlencode 'p1_coarse=0' \
--data-urlencode 'p2_coarse=1' \
--data-urlencode 'skan_total_revenue_by_currency={"USD":9.99}' \
--data-urlencode 'skan_total_admon_revenue_by_currency={"USD":1.2}' \
--data-urlencode 'skan_first_call_to_skadnetwork_timestamp=1510040127' \
--data-urlencode 'skan_last_call_to_skadnetwork_timestamp=1510090877'
HTTP
GET /api/v2/conversion_value
?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=%7B%22USD%22%3A9.99%7D
&skan_total_admon_revenue_by_currency=%7B%22USD%22%3A1.2%7D
&skan_first_call_to_skadnetwork_timestamp=1510090877
&skan_last_call_to_skadnetwork_timestamp=1510090877 HTTP/1.1
Host: sdk-api-v1.singular.net
Accept: application/json
JAVA Example
// Base URL
String baseUrl = "https://sdk-api-v1.singular.net/api/v2/conversion_value";
// Parameters
Map < String, String > params = new HashMap < > ();
params.put("a", "sdk_key_here");
params.put("p", "iOS");
params.put("i", "com.singular.app");
params.put("v", "16.1");
params.put("idfa", "DFC5A647-9043-4699-B2A5-76F03A97064B");
params.put("idfv", "21DB6612-09B3-4ECC-84AC-B353B0AF1334");
params.put("n", "__SESSION__");
params.put("app_v", "1.2.3");
params.put("skan_current_conversion_value", "7");
params.put("p1_coarse", "0");
params.put("p2_coarse", "1");
params.put("skan_first_call_to_skadnetwork_timestamp", "1510040127");
params.put("skan_last_call_to_skadnetwork_timestamp", "1510090877");
// JSON objects
JSONObject revenueJson = new JSONObject().put("USD", 9.99);
params.put("skan_total_revenue_by_currency", revenueJson.toString());
JSONObject admonJson = new JSONObject().put("USD", 1.2);
params.put("skan_total_admon_revenue_by_currency", admonJson.toString());
// Build URL with encoded parameters
StringBuilder urlBuilder = new StringBuilder(baseUrl);
urlBuilder.append('?');
for (Map.Entry < String, String > entry: params.entrySet()) {
if (urlBuilder.length() > baseUrl.length() + 1) {
urlBuilder.append('&');
}
urlBuilder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
.append('=')
.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8));
}
// Create connection
URL url = new URL(urlBuilder.toString());
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
// Get response
int responseCode = conn.getResponseCode();
BufferedReader in = new BufferedReader(
new InputStreamReader(conn.getInputStream())
);
String inputLine;
StringBuilder response = new StringBuilder();
while ((inputLine = in .readLine()) != null) {
response.append(inputLine);
} in .close();
// Check application-level status
System.out.println("HTTP Status Code: " + responseCode);
System.out.println("Response: " + response.toString());
// Disconnect
conn.disconnect();
Request Response
The following identifies a successful API response with a new conversion value returned.
HTTP Response | |
---|---|
|
The 200 - ok without any error or reason in the response body means the request was sent to the queue for processing. Response:
|
Response Parameters
The following table defines the response parameters.
Key | Description | Example Value |
---|---|---|
|
New fine conversion value |
|
|
New coarse conversion value |
|
|
Corresponds to the SKAN postback measurement periods:
Indicates which coarse conversion value key should be updated: i.e. p0_coarse, p1_coarse, p2_coarse |
|
|
Ok for successfully processed |
|
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)
- Hours calculated as:
- 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 |
Supported Platforms:
|
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:
|
Parameter | Description |
Supported Platforms:
|
The previous coarse value for p0. This is an integer between (0-2).
Example Value:
|
Parameter | Description |
Supported Platforms:
|
The previous coarse value for p1. This is an integer between (0-2).
Example Value:
|
Supported Platforms:
|
The previous coarse value for p2. This is an integer between (0-2).
Example Value:
|
Revenue Tracking | |
Supported Platforms:
|
Total of IAP Revenue for p0 excluding Ad monetization revenue.
Example Value:
|
Supported Platforms:
|
Total of IAP Revenue for p1 excluding Ad monetization revenue.
Example Value:
|
Supported Platforms:
|
Total of IAP Revenue for p2 excluding Ad monetization revenue.
Example Value:
|
Supported Platforms:
|
Total of Ad monetization revenue for p0.
Example Value:
|
Supported Platforms:
|
Total of Ad monetization revenue for p1.
Example Value:
|
Supported Platforms:
|
Total of Ad monetization revenue for p2.
Example Value:
|
Timestamp Parameters | |
Parameter | Description |
Supported Platforms:
|
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:
|
Parameter | Description |
Supported Platforms:
|
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:
|
Parameter | Description |
Supported Platforms:
|
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:
|