PAYATHON 2026

如何获取 iOS Subscription 的 paid value 和 currency?

支付小周

结论

Apple 的 Server-to-Server Notifications 不提供信用卡支付流水中的“实付金额”。

使用 App Store Server Notifications V2 时,可以验证并解码通知中的 signedTransactionInfo,读取以下字段:

  • price:交易价格,单位为货币的千分之一,即 milliunits
  • currency:ISO 4217 货币代码,例如 USD、CNY

如果当前通知不含这些字段,可以使用通知里的 transactionId 调用 App Store Server API 的 Get Transaction Info:

GET /inApps/v1/transactions/{transactionId}

接口返回的 signedTransactionInfo 也需要经过 JWS 验证和解码。

通知和 Get Transaction Info 都无法提供 Apple 最终结算给开发者的净收入、税费、佣金或实际到账金额。这些数据需要通过 App Store Connect 的 Sales and Trends 或财务报告查询。

为什么通知正文里看不到金额

App Store Server Notifications V2 的外层正文主要包含通知元数据,交易详情位于:

signedPayload
└── data
    └── signedTransactionInfo

signedPayload 和 signedTransactionInfo 都是 JWS 字符串,并非直接展开的 JSON。因此,只查看 webhook 收到的原始正文,通常找不到 price 和 currency。

旧版通知格式可能不含价格字段,对应的交易载荷也可能没有这些信息。在这种情况下,应迁移到 V2,并以 App Store Server API 返回的交易数据为准。

处理步骤

1. 验证并解码外层 signedPayload

不能只做 Base64 解码。生产环境中必须验证 JWS 签名、证书链、Bundle ID 和运行环境,以免接受伪造通知。

验证后可以得到类似结构:

{
  "notificationType": "DID_RENEW",
  "data": {
    "environment": "Production",
    "bundleId": "com.example.app",
    "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs..."
  }
}

2. 验证并解码 signedTransactionInfo

交易载荷可能包含:

{
  "transactionId": "2000000123456789",
  "originalTransactionId": "2000000012345678",
  "productId": "com.example.premium.monthly",
  "purchaseDate": 1724817600000,
  "price": 9900,
  "currency": "USD"
}

price 的单位是 milliunits,换算公式如下:

displayAmount = price / 1000

例如:

9900 / 1000 = 9.9 USD

不要按照货币通常保留两位小数的方式直接除以 100。Apple 在这里使用固定的千分之一货币单位。

Node.js 示例

可以使用 Apple 官方的 App Store Server Library 完成验签。下面只展示验签后的字段读取逻辑,其中 verifiedTransactionPayload 表示已经通过 JWS 和证书链验证的交易载荷:

function extractTransactionAmount(verifiedTransactionPayload) {
  const { transactionId, price, currency } = verifiedTransactionPayload;

  if (price == null || !currency) {
    return {
      transactionId,
      amount: null,
      currency: null,
      reason: "The transaction payload does not contain price or currency."
    };
  }

  return {
    transactionId,
    priceInMilliunits: price,
    amount: price / 1000,
    currency
  };
}

结果示例:

{
  "transactionId": "2000000123456789",
  "priceInMilliunits": 9900,
  "amount": 9.9,
  "currency": "USD"
}

进行财务计算时,尽量不要直接使用浮点数。可以一直将 price 保存为整数形式的 milliunits,仅在最终展示时格式化:

function formatMilliunits(price, currency, locale = "en-US") {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency
  }).format(price / 1000);
}

通过 Get Transaction Info 补查

如果通知中有 transactionId,但交易载荷缺失或没有保存,可以调用:

GET https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}
Authorization: Bearer {app_store_server_api_jwt}

Sandbox 环境使用:

GET https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/{transactionId}

响应结构类似:

{
  "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs..."
}

收到响应后,验证并解码 signedTransactionInfo,再读取 price 和 currency。调用接口时使用的 Bearer Token,是通过 App Store Connect In-App Purchase Key 生成的 JWT,不能用用户 receipt 或 transaction JWS 代替。

如果只有 originalTransactionId,可以通过 Get Transaction History 查询该订阅链上的交易,再找到具体的续订交易。金额需要按每个 transactionId 分别保存,因为续订过程中可能出现调价、优惠、升级、降级或货币变化。

注意事项

  • price 和 currency 表示 App Store 交易价格,不等于开发者净收入。
  • Apple 的佣金、税务调整、退款和最终结算金额,应通过 App Store Connect 报告核对。
  • 不要通过 productId 查询商品当前价格,并用它推断历史交易金额。商品价格可能已经调整,交易也可能受到优惠、促销和地区定价影响。
  • 同一订阅的不同续订交易可能价格不同,应以具体 transactionId 对应的交易载荷为准。
  • Webhook 应保存原始通知、通知 UUID、transactionId、originalTransactionId、整数形式的 price 和 currency,并对重复通知做幂等处理。
  • 受交易类型、历史数据或载荷版本影响,price 或 currency 可能不可用。业务代码应允许字段缺失,不能自行猜测金额。
  • 使用金额数据前必须完成 JWS 验签。仅拆分 JWT 并进行 Base64 解码,无法证明数据来自 Apple。

备注:内容仅供参考。