Server-to-Server - SKAdNetwork 3 Implementation Guide

SKAdNetwork 4 Available: For the latest SKAdNetwork version, see SKAdNetwork 4 Implementation Guide.

SKAdNetwork 3.0 S2S Implementation Guide

Implement Apple's SKAdNetwork 3.0 framework for privacy-compliant iOS attribution using server-to-server integration to measure app install campaign performance without compromising user identifiers.


Overview

What is SKAdNetwork?

SKAdNetwork is Apple's privacy-preserving attribution framework enabling measurement of app install ad campaign conversion rates on iOS without requiring user-level identifiers.

The framework processes attribution through App Store servers, disconnecting attribution information from user identifiers and temporal data before sending to ad networks.

Apple documentation: SKAdNetwork Framework Reference


How SKAdNetwork Works

Attribution process occurs entirely through Apple's infrastructure, ensuring user privacy while enabling campaign performance measurement.

SKAdNetwork Attribution Flow

Attribution Flow:

  1. Ad Click: User clicks ad containing SKAdNetwork signature with network, publisher, and campaign ID
  2. App Store Open: Device stores attribution data and opens App Store for install
  3. App Launch: User installs and launches app for first time
  4. Registration: App registers with SKAdNetwork framework to signal successful install
  5. Conversion Value: App optionally updates conversion value (0-63) representing post-install activity
  6. Timer Window: Device waits 24+ hours after first launch or last conversion value update
  7. Postback: App Store sends attribution postback to ad network with campaign ID, conversion value, and cryptographic signature

Privacy by Design:

  • Postback contains no device or user identifiers
  • Minimum 24-hour delay prevents temporal correlation
  • App never knows which ad user clicked
  • Network never knows which specific user installed

Capabilities & Limitations

SKAdNetwork Provides:

  • Last-Click Attribution: Works without user consent or ATT opt-in
  • Campaign Breakdown: Source, campaign, and publisher granularity
  • Conversion Values: 64 discrete values (0-63) for post-install measurement
  • Fraud Prevention: Cryptographic signatures validate attribution authenticity

Current Limitations:

  • No User-Level Data: Cannot track individual user journeys
  • No View-Through: Click-based attribution only
  • Limited Conversion Values: Single 6-bit value (0-63) per install
  • Limited Granularity: Maximum 100 campaign IDs, no ad group or creative breakdown
  • No Long Cohorts: Limited timeframe for conversion measurement
  • Fraud Exposure: Conversion value itself not signed, postbacks can be duplicated

Singular Resources

Singular provides comprehensive resources for SKAdNetwork implementation and optimization.


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
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
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

S2S Integration Architecture

Implementation Components

Server-to-server SKAdNetwork integration consists of two main implementation areas.

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

2. S2S Integration Update (Optional):

  • Enriches Singular S2S events and sessions with SKAdNetwork metadata
  • Enables implementation validation and troubleshooting
  • Provides conversion value timestamps for debugging

Client-Side Implementation

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

Implementation Responsibilities

Core Functionality

Client-side code handles two critical functions for SKAdNetwork measurement.

  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 (first call timestamp, last call timestamp, current value)

SKAdNetwork Interface

Method Definitions

Interface includes methods for framework registration, session tracking, event tracking, and revenue measurement.

SKANSnippet.h
//  SKANSnippet.h

#import <Foundation/Foundation.h>

@interface SKANSnippet : NSObject

// Register for SKAdNetwork attribution.
// Call this method as soon as possible once app is launched
+ (void)registerAppForAdNetworkAttribution;

// Track retention and cohorts by calling after each session.
// Reports session details and updates conversion value if needed.
// Conversion value updates only if new value greater than previous.
// Optional callback runs once conversion value updated.
+ (void)updateConversionValueAsync:(void(^)(int, NSError*))handler;

// Track conversion events by calling after each event.
// Reports event details and updates conversion value if needed.
// Optional callback runs once conversion value updated.
+ (void)updateConversionValueAsync:(NSString*)eventName
               withCompletionHandler:(void(^)(int, NSError*))handler;

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

// Gets current conversion value (nil if none set)
+ (NSNumber *)getConversionValue;

@end

Registration Method

registerAppForAdNetworkAttribution registers app with SKAdNetwork framework, starting 24-hour attribution timer.

Timer Behavior:

  • Device sends install notification 0-24 hours after timer expires
  • Updating conversion value with higher value resets timer for new 24-hour interval
  • Apple reference: updateConversionValue Documentation

Conversion Value Methods

Methods calculate and update conversion values based on post-install activity and configured conversion model.

Supported Activities:

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

Interface Implementation

Complete Implementation Code

Full implementation handles registration, conversion management, and Singular API communication.

SKANSnippet.m
//  SKANSnippet.m

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

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

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

static NSLock *lockObject;

@implementation SKANSnippet

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

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

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

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

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

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

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

+ (BOOL)isSkanUpdateWindowOver {        
    NSInteger timeDiff = [SKANSnippet getCurrentUnixTimestamp] - [SKANSnippet getLastSkanCallTimestamp];
    return SECONDS_PER_DAY <= timeDiff;
}

@end

Utility Methods

Metadata Management

Utility methods manage SKAdNetwork metadata storage in UserDefaults, critical for conversion value calculation.

Critical Implementation: These utilities maintain metadata required for accurate conversion value calculation. Do not modify unless necessary.

Utility Methods
+ (NSInteger)getFirstSkanCallTimestamp {
    NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults];
    return [userDefaults integerForKey:FIRST_SKAN_CALL_TIMESTAMP];
}

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

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

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

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

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

S2S Integration Update

Optionally enhance server-to-server integration with SKAdNetwork metadata for implementation validation and troubleshooting.

Mandatory Updates

Conversion Value Response

Once conversion model activated, S2S endpoints return conversion_value integer field containing next value for client-side update.

Response Handling: Parse conversion_value from S2S endpoint responses and apply to SKAdNetwork framework via client-side code.


Optional Enhancements

Metadata Parameters

Enrich S2S session and event requests with SKAdNetwork metadata to validate integration and troubleshoot implementation issues.

Parameter Description
skan_conversion_value Latest conversion value at time of request
skan_first_call_timestamp Unix timestamp of first call to SKAdNetwork API
skan_last_call_timestamp Unix timestamp of latest call to SKAdNetwork API

Metadata Extraction Code

Extract SKAdNetwork metadata values using utility methods for S2S transmission.

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

// Forward skanMetadata to your server for S2S API enrichment

Forward parameters to server-side and append to Singular S2S API requests. Complete parameter documentation: S2S API Reference


Integration Flow

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

SKAdNetwork S2S Integration Flow

Flow Stages

End-to-End Process

  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 24+ hours from last update, 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 marketing parameters based on 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.


App Lifecycle Implementation

Integrate SKAdNetwork methods at appropriate app lifecycle points for complete attribution coverage.

Implementation Example

Method Placement

Objective-C
// On app launch (in applicationDidFinishLaunching)
[SKANSnippet registerAppForAdNetworkAttribution];

// After each session handled
[SKANSnippet updateConversionValueAsync:^(int value, NSError *error) {
    if (error) {
        NSLog(@"Conversion value update failed: %@", error);
    } else {
        NSLog(@"Conversion value updated to: %d", value);
    }
}];

// After handling non-revenue events
[SKANSnippet updateConversionValueAsync:@"event_name" 
                   withCompletionHandler:^(int value, NSError *error) {
    if (error) {
        NSLog(@"Event conversion value update failed: %@", error);
    } else {
        NSLog(@"Event conversion value updated to: %d", value);
    }
}];

// After handling revenue events
[SKANSnippet updateRevenue:15.53 andCurrency:@"USD"];
[SKANSnippet updateConversionValueAsync:@"revenue_event_name" 
                   withCompletionHandler:^(int value, NSError *error) {
    if (error) {
        NSLog(@"Revenue conversion value update failed: %@", error);
    } else {
        NSLog(@"Revenue conversion value updated to: %d", value);
    }
}];

Testing Implementation

Validate SKAdNetwork implementation using Singular's test endpoint before production deployment.

Test Environment

Test Endpoint Configuration

Replace production conversion management endpoint with test endpoint to simulate app flow.

Test Endpoint URL:

https://skadnetwork-testing.singular.net/api/v1/conversion_value
  • No API key required for test endpoint
  • Returns conversion values 0-2 sequentially
  • Returns empty string after third call

Test Procedure

Validation Steps

  1. Update Endpoint: Replace SINGULAR_API_URL constant with test endpoint URL
  2. Initialize Conversion Value: Ensure default conversion value initialized to 0
  3. Generate Events: Trigger 3 different events within app
  4. Verify Updates: Confirm updateConversionValueAsync called after each event
  5. Log Values: Log returned conversion values to verify correct progression (0 → 1 → 2)
  6. Confirm Completion: After third call, verify empty response and final value retention

Expected Behavior:

  • First event: Receives conversion value 0
  • Second event: Receives conversion value 1
  • Third event: Receives conversion value 2
  • Fourth+ events: Empty response, value 2 persists

Code Changelog

Track code sample updates and critical fixes applied over time.

Version History

Date Changes
October 1, 2020
  • Fix [CRITICAL]: Lock mechanism in getConversionValueFromServer initialization corrected
  • Improved: Server response failure reason now returned in error handling
September 23, 2020
  • Fix [CRITICAL]: setConversionValue now correctly calls updateConversionValue
  • Improved: Enhanced error handling when retrieving conversion values from server
September 15, 2020
  • Changed [IMPORTANT]:getConversionValue default value changed from null to 0 when not set
  • Improved: Revenue reporting and handling enhancements
  • Improved: Asynchronous conversion value retrieval implementation