SKAdNetwork S2S 工作流:如何验证广告网络回传

opoinstall
2026-08-21
5 min read

DSP 如何处理 SKAdNetwork 回传?需求方平台(DSP)和广告网络通过建立安全的 HTTP POST 接收端点来处理 SKAdNetwork 回传,使用 U+2063 分隔符构建序列化的 UTF-8 消息字符串,针对苹果发布的公钥验证苹果的加密 ECDSA P-256 签名,并记录已验证的 transaction ID 以防止重复处理,然后再更新出价模型。

SKAdNetwork 安装验证回传是苹果签名的 HTTPS POST 通知,操作系统会将其发送给符合条件的广告网络;对于赢得归因的事件,还会选择性地发送给所宣传应用开发者配置的副本端点。为确保数据完整性,后端接收系统必须验证苹果的 ECDSA P-256 签名、校验参数序列化并执行事务级去重。

术语 定义
SKAdNetwork 苹果用于保护隐私的广告系列归因平台级框架。
安装验证回传 包含安装验证及符合条件的广告转化后归因元数据的苹果签名 JSON 负载。
ECDSA P-256 苹果用于对安装验证回传进行签名的椭圆曲线密码算法。
Transaction ID 接收方用作重复检测幂等键的唯一验证标识符。

DSP 与广告网络的 SKAdNetwork 回传接收架构

双接收管道:直接广告网络投递 vs 开发者回传端点

当发生归因的 iOS 应用安装时,苹果的归因子系统会通过 HTTPS POST 分发安装验证回传:

  • 广告网络接收:设备将主要的获胜回传(did-win: true)直接发送到苹果注册表中对应 ad-network-id 下注册的服务器 URL。
  • 开发者副本接收:如果所宣传的应用在其 Info.plist 中指定了 NSAdvertisingAttributionReportEndpoint 键,设备会同时将获胜回传的精确副本直接分发到开发者的服务器。
  • 非获胜回传路由:从 SKAdNetwork 3.0 开始,如果有多个广告网络符合归因条件但未获胜,设备会直接向这些次要符合条件的广告网络发送最多五个非获胜回传(did-win: false)。非获胜回传不会投递到开发者副本端点。

后端接收端点应返回 HTTP 200 OK。如果设备未收到 200 响应,它可能会在最多九天内重试投递多达九次。

SKAdNetwork postback ingestion and verification workflow

NSAdvertisingAttributionReportEndpoint 在开发者审计中的作用

NSAdvertisingAttributionReportEndpoint 使应用开发者能够独立于广告网络转发接收获胜回传的直接副本:

  • 独立审计:开发者可收到为其应用生成的所有获胜回传的精确副本,从而对广告网络报告进行内部验证。
  • 专用端点路径:开发者服务器必须在 https://<domain>/.well-known/skadnetwork/report-attribution/ 托管端点。
  • AdAttributionKit 区别:对于 AdAttributionKit,苹果定义了路由至 https://<domain>/.well-known/appattribution/report-attribution/ 的单独配置,该配置利用 JSON 网页签名(JWS)验证架构。

MMP 如何接收、聚合和规范化多网络 S2S 事件流

根据商业集成情况,移动衡量伙伴(MMP)可以通过开发者端转发、广告网络集成或自定义合作伙伴服务器流来接收 SKAdNetwork 数据:

  • 多源接收:接收从开发者端点转发的已验证回传数据以及直接的广告网络报告流。
  • 跨流去重:在共享的广告网络和开发者副本之间使用唯一的 transaction-id 规范化并去重记录。
  • 下游 BI 规范化:将粗粒度和细粒度的转化值映射到客户定义的收入模型和漏斗事件。

另请参阅:SKAdNetwork ──> 移动归因模型

密码学验证:验证苹果的 ECDSA P-256 签名

理解密码学技术栈:NIST 曲线 P-256 (secp256r1) 配合 SHA-256

每个 SKAdNetwork 回传都包含一个 attribution-signature 字段。该加密签名由苹果使用带有 NIST P-256 (secp256r1) 曲线和 SHA-256 摘要的椭圆曲线数字签名算法(ECDSA)生成。

该签名验证了两个基本的安全属性:

  • 真实性:回传是由苹果平台子系统直接在已验证的设备上生成的,并非由对抗性客户端或代理伪造。
  • 完整性:签名所涵盖的参数在传输过程中未被篡改。

使用苹果发布的 SKAdNetwork 公钥

为了验证签名,接收服务器必须加载苹果的官方公钥。对于 SKAdNetwork 2.1 及更高版本,苹果在其开发者文档中发布了专用的 NIST P-256 公钥:

  • 密钥初始化:在服务器初始化期间,公钥作为标准的 X.509/DER 公钥对象加载到内存中。
  • 非对称签名检查:验证引擎会重建精确的 UTF-8 序列化消息字符串,计算 SHA-256 哈希,并针对重建的消息验证经过 Base64 解码的 attribution-signature

[Device / Subsystem] ──► [Dispatches Signed JSON Postback]
                                     │
                                     ▼
                  [DSP / Ad Network Ingestion Endpoint]
                  (HTTPS POST to registered postback URL)
                                     │
                                     ▼
                  [Parse JSON & Reconstruct Message String]
                  (Concatenate UTF-8 fields with \u2063)
                                     │
                                     ▼
                  [ECDSA P-256 Public Key Signature Verification]
                                     │
                      ┌──────────────┴──────────────┐
                      ▼                                                          ▼
               [Signature Valid]                                         [Signature Invalid]
                      │                                                          │
                      ▼                                                          ▼
            [Atomic Deduplication]                                       [Log Error & Discard]
            (Check transaction-id)
                      │
                      ▼
            [Process Attribution]
SKAdNetwork ECDSA P 256 signature verification flow

为什么仅靠哈希是不够的:非对称签名验证

由于苹果使用其私钥对负载进行签名且不分发共享密钥,因此无法使用对称验证(如 HMAC-SHA256)。接收引擎必须使用标准密码学库(如 OpenSSL、Node.js crypto 或 Python cryptography)实现标准的非对称公钥签名验证。

构建用于签名验证的消息字符串

严格的序列化协议:不可见分隔符(\u2063)的作用

苹果指定了精确的 UTF-8 字节序列化格式来构建用于签名验证的消息字符串。参数必须以精确的顺序进行连接,并由不可见的 Unicode 字符 \u2063(U+2063 不可见分隔符,UTF-8 字节序列 0xE2 0x81 0xA3)分隔:

Message=Param1    "2˘063"    Param2    "2˘063"  Paramn\text{Message} = \text{Param}_1 \;\|\; \text{"\u2063"} \;\|\; \text{Param}_2 \;\|\; \text{"\u2063"} \dots \|\; \text{Param}_n

替换空格、标准标点符号或替代的 Unicode 分隔符将导致密码学验证失败。

SKAN 4 的特定版本参数顺序

根据关于验证安装验证回传的苹果开发者文档,SKAdNetwork 4.0 回传的参数必须按照以下准确顺序进行序列化:

  1. version(例如 "4.0"
  2. ad-network-id(例如 "example123.skadnetwork"
  3. source-identifier(例如 "4821"
  4. app-id(例如 1234567890
  5. transaction-id(例如 "6a8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d"
  6. redownload(例如作为小写字符串的 "true""false"
  7. source-app-id(用于应用到应用广告) source-domain(用于 Safari 中的网页到应用广告),仅当回传中存在时包含
  8. fidelity-type(例如 StoreKit 渲染的广告或 SKAdNetwork 归因的网页广告为 1;穿山甲/浏览型广告为 0
  9. did-win(例如作为小写字符串的 "true""false"
  10. postback-sequence-index(例如 012

至关重要的 SKAN 4 规范:转化值不包含在签名中

在 SKAdNetwork 4.0 中,苹果的签名不包含 conversion-valuecoarse-conversion-value,即使这些字段之一存在于 JSON 负载中也是如此。SKAN 4 的序列化字符串以 postback-sequence-index 结尾。尝试将转化值追加到消息字符串将导致验证失败。

下面的 JSON 负载展示了完整的 SKAdNetwork 4.0 回传架构。下面的签名是一个示例占位符,无法通过密码学验证;进行单元测试时,请使用官方验证文档中苹果提供的已签名示例:

{
  "version": "4.0",
  "ad-network-id": "example123.skadnetwork",
  "source-identifier": "4821",
  "app-id": 1234567890,
  "transaction-id": "6a8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
  "redownload": false,
  "source-app-id": 9876543210,
  "fidelity-type": 1,
  "did-win": true,
  "postback-sequence-index": 0,
  "conversion-value": 47,
  "attribution-signature": "MEQCIFz8...SAMPLE_CRYPTOGRAPHIC_SIGNATURE...=="
}

SKAN 4 signature message serialization with U 2063

处理多窗口 SKAN 4.0 负载与开发者端点

跨连续转化窗口解析 postback-sequence-index

在 SKAdNetwork 4.0 中,转化会生成跨越应用首次启动后最多 35 天的转化窗口的回传,实际投递则发生在苹果随机化的窗口后延迟之后。后端接收系统会解析 postback-sequence-index 字段,以将转化数据分配到正确的生命周期窗口:

  • 索引 0(窗口 1:第 0–2 天):包含细粒度转化值(0–63)、粗粒度转化值(lowmediumhigh),或者该字段缺失。
  • 索引 1(窗口 2:第 3–7 天):对于回传数据层级 1–3,在提供时可能会披露 coarse-conversion-valuelowmediumhigh);层级 0 不符合第二个或第三个回传的条件。
  • 索引 2(窗口 3:第 8–35 天):对于回传数据层级 1–3,在提供时可能会披露 coarse-conversion-valuelowmediumhigh);层级 0 不符合第二个或第三个回传的条件。

管理细粒度与粗粒度转化值

接收解码器必须考虑到负载的可变性:

  • 互斥性:苹果规定安装验证回传可以包含 conversion-valuecoarse-conversion-value,但绝不能同时包含两者。
  • 缺失转化值:如果分配的回传数据层级较低(层级 0),则会从 JSON 负载中省略转化值字段。

防御重放攻击与伪造的转化负载

transaction-id 作为去中心化去重键的作用

每个 SKAdNetwork 回传都包含一个唯一的 transaction-id UUID。苹果文档建议接收方使用此标识符作为幂等键,以检测并丢弃重复的转化回传。

由于回传监听器是公开可访问的 HTTPS 端点,恶意攻击者可能会通过捕获有效回传并重复提交来尝试重放攻击,从而人为抬高转化指标。

实现分布式内存缓存与持久化账本

苹果并未规定通用的去重保留期。生产环境接收方应根据其对账和重放防御要求,为已验证的事务 ID 维护持久化的幂等记录;Redis TTL 可用作热缓存优化,而不是唯一的权威重复账本:

  1. 优先进行密码学验证:在将事务 ID 提交到存储之前,针对苹果的公钥完整验证 ECDSA 签名。
  2. 原子去重:执行由持久化关系数据库或文档数据库唯一约束支持的原子写入操作(例如 Redis SET key value NX EX <seconds>)。
  3. 去重范围:在缓存层中设置一个运营保留窗口,该窗口应涵盖预期的回传投递、网络重试(苹果对失败投递最多重试 9 天)以及下游对账。

SKAdNetwork replay defense and deduplication pipeline


下方的后端实现展示了 Python 中的签名验证、架构验证和原子去重:

import base64
import json
import redis
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.exceptions import InvalidSignature

# Initialize Redis client for hot-cache transaction deduplication
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

# Official Apple SKAdNetwork 2.1+ Public Key (Base64 DER encoded, published by Apple)
APPLE_SKAN_PUBLIC_KEY_B64 = (
    "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWdp8GPcGqmhgzEFj9Z2nSpQVdday"
    "aPe4FMzqM9wib1+aHaaIzoHoLN9zW4K8y4SPykE3YVK3sVqW6Af0lfx3gg=="
)

# Exact Apple-specified invisible separator: U+2063 INVISIBLE SEPARATOR (UTF-8: 0xE2 0x81 0xA3)
SEPARATOR = "\u2063"

def construct_skan4_message_bytes(payload: dict) -> bytes:
    """
    Constructs the serialized UTF-8 message string for SKAN 4.0 signature verification.
    Apple specification explicitly EXCLUDES conversion-value and coarse-conversion-value from the signature.
    """
    parts = [
        str(payload["version"]),
        str(payload["ad-network-id"]),
        str(payload["source-identifier"]),
        str(payload["app-id"]),
        str(payload["transaction-id"]),
        "true" if payload["redownload"] is True else "false"
    ]

    # Include source-app-id (app ad) OR source-domain (web ad) if present
    if payload.get("source-app-id") is not None:
        parts.append(str(payload["source-app-id"]))
    elif payload.get("source-domain") is not None:
        parts.append(str(payload["source-domain"]))

    parts.append(str(payload["fidelity-type"]))
    parts.append("true" if payload["did-win"] is True else "false")
    parts.append(str(payload["postback-sequence-index"]))

    # Join with U+2063 separator and encode to UTF-8
    message_string = SEPARATOR.join(parts)
    return message_string.encode('utf-8')

def verify_and_ingest_skan4_postback(postback_json_str: str) -> dict:
    """
    Validates payload schema, verifies the ECDSA P-256 signature against Apple's public key,
    and performs atomic transaction-id deduplication.
    """
    try:
        payload = json.loads(postback_json_str)
    except Exception:
        return {"status": "REJECTED", "reason": "INVALID_JSON_FORMAT"}

    # Version Gate: Enforce SKAN 4.0 payload handling
    if payload.get("version") != "4.0":
        return {"status": "REJECTED", "reason": "UNSUPPORTED_SKAN_VERSION"}

    # Schema Validation: Required fields for SKAN 4.0
    required_fields = [
        "version", "ad-network-id", "source-identifier", "app-id",
        "transaction-id", "redownload", "fidelity-type", "did-win",
        "postback-sequence-index", "attribution-signature"
    ]
    for field in required_fields:
        if field not in payload:
            return {"status": "REJECTED", "reason": f"MISSING_REQUIRED_FIELD_{field.upper()}"}

    # Strict Type Validation
    if not isinstance(payload["redownload"], bool):
        return {"status": "REJECTED", "reason": "INVALID_TYPE_REDOWNLOAD"}
    if not isinstance(payload["did-win"], bool):
        return {"status": "REJECTED", "reason": "INVALID_TYPE_DID_WIN"}
    if payload["postback-sequence-index"] not in (0, 1, 2):
        return {"status": "REJECTED", "reason": "INVALID_SEQUENCE_INDEX"}
    if payload["fidelity-type"] not in (0, 1):
        return {"status": "REJECTED", "reason": "INVALID_FIDELITY_TYPE"}

    # Enforce mutual exclusivity between source-app-id and source-domain
    has_source_app = payload.get("source-app-id") is not None
    has_source_domain = payload.get("source-domain") is not None
    if has_source_app and has_source_domain:
        return {"status": "REJECTED", "reason": "CONFLICTING_SOURCE_FIELDS"}

    signature_b64 = payload["attribution-signature"]
    transaction_id = payload["transaction-id"]

    try:
        # Step 1: Reconstruct the exact UTF-8 serialized message
        message_bytes = construct_skan4_message_bytes(payload)
        signature_der = base64.b64decode(signature_b64, validate=True)

        # Step 2: Verify ECDSA P-256 / SHA-256 signature using Apple's published public key
        apple_public_key = load_der_public_key(base64.b64decode(APPLE_SKAN_PUBLIC_KEY_B64))
        apple_public_key.verify(
            signature_der,
            message_bytes,
            ec.ECDSA(hashes.SHA256())
        )
    except InvalidSignature:
        return {"status": "REJECTED", "reason": "INVALID_CRYPTOGRAPHIC_SIGNATURE"}
    except Exception as e:
        return {"status": "ERROR", "reason": f"VERIFICATION_FAILED: {str(e)}"}

    # Step 3: Atomic Deduplication via Redis (Hot cache layer)
    # Note: In production, pair this hot cache with a persistent unique database constraint.
    # 14-day TTL (1,209,600 seconds) serves as an illustrative receiver cache policy covering retries.
    is_new = redis_client.set(f"skan_tx:{transaction_id}", "1", nx=True, ex=1209600)
    if not is_new:
        return {"status": "DUPLICATE", "reason": "TRANSACTION_ALREADY_PROCESSED"}

    return {
        "status": "VERIFIED",
        "transaction_id": transaction_id,
        "sequence_index": payload["postback-sequence-index"],
        "did_win": payload["did-win"]
    }

将回传接入实时竞价模型与 CPA 优化器

将边缘接收与异步处理解耦

高吞吐量的 DSP 在营销活动高峰期会处理大量的回传数据。同步的下游处理可能会引入延迟瓶颈。

企业架构会实现异步管道:

  1. 边缘接收器:接受传入的 HTTP POST,验证签名真实性,对 transaction-id 执行原子去重,并立即返回 HTTP 200 OK
  2. 事件队列:将已验证的负载发布到分布式事件代理(例如 Apache Kafka 或 AWS SQS)。
  3. 竞价与分析工作线程:消费事件流,将转化值映射到收入指标,并更新实时竞价(RTB)目标 CPA 模型。

利用分层源标识符

根据回传数据层级,第一个获胜的回传可能会公开分层 source-identifier 的两位、三位或四位数字。这些数字的语义由广告网络自身的源标识符分类法定义。竞价系统应根据网络自身的广告系列元数据解析接收到的源标识符,而不是假设位数与特定排版或创意粒度之间存在通用映射。

对照矩阵:苹果直接投递 vs MMP S2S 接收

功能维度 苹果直接投递(广告网络) 开发者端点(NSAdvertising... MMP S2S 接收管道
接收方 已注册的广告网络 所宣传的应用开发者 移动衡量伙伴
归因范围 该网络的获胜回传 应用获胜回传的副本 多网络聚合视图
签名验证 由广告网络后端执行 由开发者后端执行 取决于具体实现(合作伙伴流)
非获胜回传 若符合条件则接收(did-win: false 不会投递到开发者端点 可能通过合作伙伴流提供
主要用例 直接竞价器与目标 CPA 优化 内部仓库审计与验证 跨渠道表现仪表板

常见问题解答 (FAQ)

使用哪个公钥来验证苹果的回传签名?
苹果在其开发者文档中发布了用于 SKAdNetwork 2.1+ 安装验证的官方 NIST P-256 公钥。接收服务器会以 X.509/DER 格式加载此公钥,以验证传入的签名。
为什么有效的 SKAdNetwork 回传会通不过签名验证?
签名验证失败通常是由序列化错误引起的:使用了错误的分隔符字符(使用了 `\u2060` 而非 `\u2063`)、参数顺序不正确、错误地将转化值追加到了 SKAN 4 消息字符串中,或者错误处理了布尔字符串编码(`"true"` vs `"false"`)。
所宣传应用的开发者端点可以接收非获胜的 SKAdNetwork 回传吗?
不可以。在配置后,开发者副本端点仅接收获胜的安装验证回传的副本。最多五个非获胜回传(<code>did-win: false</code>)会直接发送给其他符合条件的广告网络,而不会发送到开发者副本端点。

总结与决策框架

大规模处理 SKAdNetwork 回传需要将低延迟的边缘接收与严格的密码学验证及事务级去重相结合。由于苹果回传会直接影响预算分配和竞价算法,因此验证 ECDSA 签名并强制执行 transaction-id 幂等性可以保护接收管道免受伪造或篡改回传以及重复重放处理的影响。

为了将平台中介的 SKAdNetwork 报告与微观层面的用户引导和即时深度链接路由相结合,工程团队会在平台 API 旁边部署第一方路由架构。

要详细了解如何配置服务端归因回传和深度链接管道,请查阅 Openinstall 文档

相关资料

  • 概念:S2S 回传、密码学验证、ECDSA P-256、重放攻击防御、事务去重

  • 技术:Apple SKAdNetwork、Apple AdAttributionKit、Redis 内存缓存、Openinstall Mobile SDK

  • 标准:IETF RFC 8259(JSON 数据交换)、RFC 5480(椭圆曲线密码学)

  • API:StoreKit SKAdNetwork API、Apple S2S 回传投递规范、Openinstall S2S API

官方文档

Share this article