CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

71

Quality

89%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

adattributionkit-patterns.mdskills/adattributionkit/references/

AdAttributionKit Patterns

Extended patterns for postback verification, server-side handling, testing, SKAdNetwork migration, alternative marketplaces, and attribution rules configuration.

Contents

Postback Verification

The device signs postbacks with Apple's private key. Verify the JWS signature before counting any conversion.

Select the correct public key

Apple uses different keys depending on the environment:

Key IDEnvironment
apple-cas-identifier/0Production
apple-development-identifier/0Development (end-to-end flows)
apple-development-identifier/1Development (developer settings)

Verify with CryptoKit

import CryptoKit
import Foundation

enum PostbackVerificationError: Error {
    case unknownKeyID
    case invalidPublicKey
    case invalidJWS
    case signatureVerificationFailed
}

struct PostbackVerifier {

    static func verify(jwsString: String) throws -> [String: Any] {
        let parts = jwsString.split(separator: ".").map(String.init)
        guard parts.count == 3 else {
            throw PostbackVerificationError.invalidJWS
        }

        let headerData = try base64URLDecode(parts[0])
        let payloadData = try base64URLDecode(parts[1])
        let signatureData = try base64URLDecode(parts[2])

        guard let header = try JSONSerialization.jsonObject(
            with: headerData
        ) as? [String: String],
              let keyID = header["kid"] else {
            throw PostbackVerificationError.invalidJWS
        }

        let publicKey = try getPublicKey(forKeyID: keyID)
        let signedData = Data("\(parts[0]).\(parts[1])".utf8)
        let signature = try P256.Signing.ECDSASignature(
            rawRepresentation: signatureData
        )

        guard publicKey.isValidSignature(signature, for: signedData) else {
            throw PostbackVerificationError.signatureVerificationFailed
        }

        guard let payload = try JSONSerialization.jsonObject(
            with: payloadData
        ) as? [String: Any] else {
            throw PostbackVerificationError.invalidJWS
        }

        return payload
    }

    static func getPublicKey(
        forKeyID keyID: String
    ) throws -> P256.Signing.PublicKey {
        let base64Key: String
        switch keyID {
        case "apple-cas-identifier/0":
            base64Key = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWdp8GPcGqmhgzEFj9Z2nSpQVddayaPe4FMzqM9wib1+aHaaIzoHoLN9zW4K8y4SPykE3YVK3sVqW6Af0lfx3gg=="
        case "apple-development-identifier/0":
            base64Key = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELeEDzpJEP+/qRSE5hJVC1p1J0ssUnQGMzBBbvnACBok8OVGGLgxL0myrKiy6lvRtSlLRsWit87i+vftD8AEqeQ=="
        case "apple-development-identifier/1":
            base64Key = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8YzdO7eM97s/IJ25kdW5CZ3A14USE5IJ5Ha/vhWaxI6UBI1ZxCEvjrKxVluVGe6qWwF1BDFq+QHqKfH5u+wxHQ=="
        default:
            throw PostbackVerificationError.unknownKeyID
        }

        guard let keyData = Data(base64Encoded: base64Key) else {
            throw PostbackVerificationError.invalidPublicKey
        }
        return try P256.Signing.PublicKey(derRepresentation: keyData)
    }

    private static func base64URLDecode(_ string: String) throws -> Data {
        var base64 = string
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
        while base64.count % 4 != 0 {
            base64.append("=")
        }
        guard let data = Data(base64Encoded: base64) else {
            throw PostbackVerificationError.invalidJWS
        }
        return data
    }
}

Ignore invalid postbacks

In production, always use the apple-cas-identifier/0 key. Discard any postback that fails signature verification -- do not count it as a conversion.

Server-Side Postback Handling

Postback JSON structure

The device sends an HTTPS POST with JSON:

{
  "jws-string": "<JWS compact serialization>",
  "conversion-value": 24,
  "ad-interaction-type": "click",
  "country-code": "US"
}

The conversion-value, ad-interaction-type, and country-code fields are outside the JWS and present only at higher crowd anonymity tiers.

JWS payload fields

Decoded from the JWS jws-string:

FieldTypeDescription
postback-identifierStringUnique ID for deduplication
ad-network-identifierStringAd network that signed the impression
source-identifierString2-4 digit hierarchical source ID
advertised-item-identifierIntegerApp Store item ID of the advertised app
publisher-item-identifierIntegerApp Store item ID of the publisher app (Tier 3)
marketplace-identifierStringBundle ID of the marketplace (if not App Store)
impression-typeStringAlways "app-impression"
did-winBooleanWhether this postback won attribution
postback-sequence-indexInteger0, 1, or 2 (conversion window index)
conversion-typeString"download", "redownload", or "re-engagement"

Deduplication

Use postback-identifier to deduplicate. Discard any postback with a postback-identifier already processed.

Response requirements

Respond with HTTP 200 immediately. On HTTP 500, the device retries up to 9 times over a maximum of 9 days. Process the postback asynchronously.

Winning vs. nonwinning postbacks

  • Winning: one per conversion; contains full data per tier.
  • Nonwinning: up to 5 ad networks receive one each per install conversion. Re-engagement has no nonwinning postbacks.

JWS Impression Generation

Ad networks generate signed JWS impressions on their servers and deliver them to the publisher app.

JOSE header

{
    "alg": "ES256",
    "kid": "example.adattributionkit"
}

Use ES256 encryption. The kid is the registered ad network ID.

JWS payload

{
    "impression-identifier": "7aa9f8cc-5689-4c02-b963-22ca22136015",
    "publisher-item-identifier": 525463029,
    "impression-type": "app-impression",
    "ad-network-identifier": "example.adattributionkit",
    "source-identifier": 5239,
    "timestamp": 1679790422446,
    "advertised-item-identifier": 1108187390,
    "eligible-for-re-engagement": true
}
FieldRequiredNotes
impression-identifierYesUUID generated per impression
publisher-item-identifierYesSet to 0 for development impressions
impression-typeYesAlways "app-impression"
ad-network-identifierYesMust match kid in header
source-identifierYes2-4 digit hierarchical ID
timestampYesMilliseconds since 1970
advertised-item-identifierYesApp Store item ID
eligible-for-re-engagementNotrue to enable re-engagement

Signing

Sign with the private key generated during ad network registration using ES256 (ECDSA with P-256 and SHA-256). The compact JWS format is:

BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)

Key generation

Generate the ECDSA key pair using OpenSSL:

# Private key
openssl ecparam -name prime256v1 -genkey -noout \
    -out company_adattributionkit_private_key.pem

# Public key (share with Apple during registration)
openssl ec -in company_adattributionkit_private_key.pem \
    -pubout -out company_adattributionkit_public_key.pem

Testing with Developer Mode

Enable Developer Mode (iOS 18+)

  1. Enable Developer Mode on the test device (Settings > Privacy & Security > Developer Mode).
  2. Go to Settings > Developer > Ad Attribution Testing.
  3. Enable the AdAttributionKit Developer Mode switch.

Developer Mode reduces conversion windows from days to minutes and postback delays from hours to 5-10 minutes.

Reduced time windows in Developer Mode

WindowNormal durationDeveloper Mode
1stDays 0-20-3 minutes
2ndDays 3-73-6 minutes
3rdDays 8-356-9 minutes
Delay24-48 hours5-10 minutes

Create development impressions

Use publisher-item-identifier: 0 in the JWS payload to create development impressions. The system prioritizes production impressions over development impressions if both exist.

Inspect postbacks via HTTP proxy

With Developer Mode enabled, configure an HTTP proxy on the device (Settings > Wi-Fi > network > HTTP Proxy > Manual) to intercept AdAttributionKit postbacks.

Important testing notes

  • Use a production Apple ID (not sandbox) for testing.
  • Developer Mode automatically disables after two weeks.
  • Development postbacks use apple-development-identifier/0 as the key ID.
  • Production impressions always take priority over development impressions.

Creating Postbacks in Developer Settings

For testing without a publisher app, use the on-device development postbacks tool.

Steps

  1. Enable Developer Mode on the test device.
  2. Go to Settings > Developer > Ad Attribution Testing > Development Postbacks.
  3. Enter the advertised app's bundle identifier (it must be installed).
  4. Provide a server URL for receiving the postback.
  5. Configure postback properties (interaction type, conversion type, conversion windows).
  6. Optionally adjust crowd anonymity tiers to test different data levels.

Transmit postbacks on demand

Tap "Transmit Development Postbacks" to send eligible postbacks immediately instead of waiting for automatic delivery.

Developer Settings postback differences

  • kid in JWS header: apple-development-identifier/1
  • ad-network-identifier: development.adattributionkit
  • advertised-item-identifier: 0 for Xcode-installed apps; actual ID for App Store / marketplace installs.

Migration from SKAdNetwork

AdAttributionKit is the successor to SKAdNetwork. Both frameworks coexist and the system evaluates impressions from both when determining attribution winners.

Key differences

FeatureSKAdNetworkAdAttributionKit
Marketplace supportApp Store onlyApp Store + alt
Re-engagementNoYes (iOS 18+)
Property list keySKAdNetworkItemsAdNetworkIdentifiers
Postback copy keyNSAdvertisingAttributionReportEndpointAttributionCopyEndpoint
Conversion value APISKAdNetwork.updatePostbackConversionValuePostback.updateConversionValue
Framework importStoreKitAdAttributionKit
Conversion tagsNoYes (iOS 18.4+)
Attribution rules configNoYes (iOS 26+)

Dual framework support

If supporting both frameworks during migration, call both update APIs:

import AdAttributionKit
import StoreKit

func updateConversion(value: Int, coarse: CoarseConversionValue) async {
    // Update AdAttributionKit
    do {
        try await Postback.updateConversionValue(
            value,
            coarseConversionValue: coarse,
            lockPostback: false
        )
    } catch {
        print("AdAttributionKit update failed: \(error)")
    }

    // Update SKAdNetwork
    // Note: SKAdNetwork bridges to AdAttributionKit automatically,
    // but calling both ensures coverage if only one framework has
    // active postbacks.
    do {
        try await SKAdNetwork.updatePostbackConversionValue(
            value,
            coarseValue: .high,
            lockWindow: false
        )
    } catch {
        print("SKAdNetwork update failed: \(error)")
    }
}

When an app calls the SKAdNetwork update API, SKAdNetwork automatically bridges the conversion values to AdAttributionKit. Calling both APIs explicitly is still recommended for clarity and coverage.

Ad network ID compatibility

Ad network IDs ending in .adattributionkit and .skadnetwork are compatible across both frameworks. An ad network registered with SKAdNetwork can reuse its existing ID with AdAttributionKit.

Publisher app migration

Include ad network IDs for both frameworks in Info.plist:

<!-- SKAdNetwork (legacy) -->
<key>SKAdNetworkItems</key>
<array>
    <dict>
        <key>SKAdNetworkIdentifier</key>
        <string>example123.skadnetwork</string>
    </dict>
</array>

<!-- AdAttributionKit -->
<key>AdNetworkIdentifiers</key>
<array>
    <string>example123.adattributionkit</string>
</array>

Alternative Marketplaces

AdAttributionKit supports alternative app marketplaces in addition to the App Store. The marketplace-identifier field in postbacks contains the bundle ID of the marketplace from which the conversion originated.

  • For App Store conversions: marketplace-identifier is com.apple.AppStore.
  • For alternative marketplace conversions: the bundle ID of that marketplace.
  • The marketplace-identifier is omitted from Tier 0 postbacks.

No additional client-side setup is required. The system automatically includes the marketplace identifier based on where the app was installed from.

Attribution Rules Configuration

iOS 26+ supports configurable attribution windows and cooldown periods through Info.plist.

Attribution windows

Control how long impressions remain eligible for attribution:

<key>AdAttributionKitConfigurations</key>
<dict>
    <key>AttributionWindows</key>
    <dict>
        <!-- Global settings for all ad networks -->
        <key>global</key>
        <dict>
            <key>install</key>
            <dict>
                <key>view</key>
                <integer>3</integer>  <!-- 1-7 days, default 1 -->
                <key>click</key>
                <integer>14</integer> <!-- 1-30 days, default 30 -->
            </dict>
        </dict>

        <!-- Per-network override -->
        <key>example.adattributionkit</key>
        <dict>
            <key>install</key>
            <dict>
                <key>click</key>
                <integer>7</integer>
                <key>ignoreInteractionType</key>
                <string>view</string>
            </dict>
        </dict>
    </dict>
</dict>

Precedence: ad network config > global config > system default.

The ignoreInteractionType key is only valid in per-ad-network configurations, not in the global section. Set it to "view" or "click" to ignore that interaction type from the specified ad network during attribution.

Attribution cooldown

Control the minimum time between conversions:

<key>AdAttributionKitConfigurations</key>
<dict>
    <key>AttributionCooldown</key>
    <dict>
        <!-- Hours after install before accepting new conversions -->
        <key>install-cooldown-hours</key>
        <integer>24</integer>  <!-- 0-720 hours -->

        <!-- Hours after re-engagement before accepting new conversions -->
        <key>re-engagement-cooldown-hours</key>
        <integer>12</integer>  <!-- 0-720 hours -->
    </dict>
</dict>

Conversion Tags for Overlapping Windows

When multiple re-engagement conversions occur close together, conversion tags (iOS 18.4+) let the advertised app update specific postbacks independently.

Receive the tag

The system delivers the conversion tag through the re-engagement URL:

func handleReengagementURL(_ url: URL) {
    let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
    if let tagItem = components?.queryItems?.first(where: {
        $0.name == Postback.reengagementOpenURLParameter
    }) {
        let conversionTag = tagItem.value
        // Persist the tag alongside an internal conversion identifier
        saveConversionTag(conversionTag)
    }
}

Update a specific conversion

func updateSpecificConversion(tag: String, value: Int) async {
    let update = PostbackUpdate(
        fineConversionValue: value,
        lockPostback: false,
        conversionTag: tag,
        conversionTypes: [.reengagement]
    )
    do {
        try await Postback.updateConversionValue(update)
    } catch {
        print("Failed to update tagged conversion: \(error)")
    }
}

If no conversion tag is specified, AdAttributionKit updates the most recent conversion (pre-iOS 18.4 behavior).

Error Handling

AdAttributionKitError cases

ErrorCause
.impressionExpiredImpression older than its time window
.missingAttributionViewhandleTap() called without UIEventAttributionView
.invalidImpressionJWSComponentsJWS string is not valid compact JWS format
.invalidImpressionJWSHeaderJWS header missing required fields or wrong algorithm
.invalidImpressionJWSPayloadJWS payload missing required fields
.invalidImpressionJWSSignatureSignature verification failed
.invalidConversionTagConversion tag format is invalid
.conversionTagNotSupportedConversion tag used on unsupported OS version
.unknownUnrecoverable internal error

Defensive impression handling

func safeHandleImpression(_ jwsString: String) async {
    do {
        let impression = try await AppImpression(compactJWS: jwsString)
        try await impression.handleView()
    } catch let error as AdAttributionKitError {
        switch error {
        case .invalidImpressionJWSComponents,
             .invalidImpressionJWSHeader,
             .invalidImpressionJWSPayload,
             .invalidImpressionJWSSignature:
            // Log and request a new impression from the ad network
            reportInvalidImpression(jwsString, error: error)
        case .impressionExpired:
            // Request a fresh impression
            refreshImpression()
        case .missingAttributionView:
            // Only relevant for handleTap, not handleView
            break
        default:
            print("AdAttributionKit error: \(error)")
        }
    } catch {
        print("Unexpected error: \(error)")
    }
}

skills

adattributionkit

CHANGELOG.md

README.md

tile.json