导读:本期聚焦于小鱼创作的《iOS内购订阅状态如何查询?App Store Server API、JWT生成与状态码解析》,敬请观看详情。自动续期订阅的本地票据经常和苹果后台的真实状态不一致,尤其是用户申请退款、关闭续订后又重新开启或者跨设备恢复购买时,客户端拿到的凭证可能已经过期。要想准确判断用户是否仍具备订阅权益,需要在服务端调用 App Store Server API 查询交易标识对应的订阅状态。整个过程涉及使用 App Store Connect 后台的密钥生成 JWT 作为鉴权凭证,再请求苹果的订阅查询接口,并对返回的会话令牌、自动续订字段以及不同 HTTP 状态码做对应处理。本文会拆解钥匙 ID、Issuer ID、Bundle ID 与私钥文件的作用,展示 Node.js 生成 ES256 签名 JWT 的完整代码,并解析 200、401、429、500 等常见响应状态及数据字段,帮助后端快速落地可靠的订阅状态校验。

在 iOS 应用内购里,自动续期订阅的最终状态并不完全由客户端决定。苹果虽然提供了本地收据和 StoreKit 框架来读取交易信息,但退款、家庭共享、优惠活动、用户关闭自动续订等操作经常发生在苹果服务器端,客户端只能拿到某个时间点的快照。为了保证会员权益不被错误发放,服务端必须使用 App Store Server API 主动查询某个 transactionId 对应的最新订阅状态,并且通过 JWT 完成接口鉴权。

iOS内购订阅状态如何查询?App Store Server API、JWT生成与状态码解析

App Store Server API 的接入准备与端点选择

苹果在 2021 年后逐步将服务端验证从旧的 verifyReceipt 迁移到更细粒度的 App Store Server API。新的 API 不再需要上传整张收据,而是通过客户端在 SKPaymentQueue 回调中获取到的 transactionId 直接查询单笔交易或订阅状态。生产环境的基础路径是 https://api.storekit.itunes.apple.com,沙盒环境则使用 https://api.storekit-sandbox.itunes.apple.com。两者返回的数据结构一致,但沙盒数据只来自沙盒测试账号的购买行为。

在使用接口前需要在 App Store Connect 后台完成三项配置。第一,在用户和访问中创建一个密钥,下载后缀为 .p8 的私钥文件并记录对应的 Key ID。第二,在通用信息里找到 Issuer ID,这是一个由字母和数字组成的标识,用于标记签发 JWT 的主体。第三,确保要查询的订阅所属的 Bundle ID 与 JWT 中的 bundleId 字段一致,否则苹果会直接返回 403 无权限错误。这三项配置缺一不可,而且私钥文件只能下载一次,丢失后需要重新创建密钥。

查询单笔订阅状态的端点为 GET /inApps/v1/subscriptions/{transactionId},路径参数是客户端上报的交易标识。该接口默认返回苹果签名的 JWS 数据,其中 lastTransactions 数组包含最近一笔交易和续期信息。另一个常用端点是 GET /inApps/v1/transactions/{transactionId},它返回单笔交易的详细历史。一般情况下做订阅状态校验,优先使用订阅查询端点,因为响应中已经聚合了自动续订状态、过期意图、宽限期等关键字段。

JWT 生成细节与签名错误排查

调用 App Store Server API 时,请求头必须携带 Authorization: Bearer <JWT>。这个 JWT 使用 ES256 算法签名,也就是基于 P-256 椭圆曲线的 ECDSA 签名,和普通的 RS256 或者 HS256 差别很大,因此不能使用字符串密钥直接生成,必须读取 .p8 文件中的 PEM 格式私钥。JWT 头部需要包含 alg 和 kid 两个字段,其中 kid 是创建密钥时的 Key ID。载荷字段常见的有 iss 表示 Issuer ID,iat 表示签发时间戳,exp 表示过期时间戳,aud 固定为 appstoreconnect-v1,bid 表示应用的 Bundle ID。

下面这段 Node.js 代码演示了如何读取私钥并生成有效期 20 分钟以内的 JWT。苹果规定 exp 不能晚于 iat 超过 20 分钟,很多签名失败都是因为服务器时间不同步或者过期时间设置过长。

const fs = require('fs');
const jwt = require('jsonwebtoken');

function generateAppStoreJwt() {
  const privateKey = fs.readFileSync('./SubscriptionKey_ABC123.p8', 'utf8');
  const keyId = 'YOUR_KEY_ID';
  const issuerId = 'YOUR_ISSUER_ID';
  const bundleId = 'com.example.yourapp';
  const now = Math.floor(Date.now() / 1000);

  const payload = {
    iss: issuerId,
    iat: now,
    exp: now + 1200,
    aud: 'appstoreconnect-v1',
    bid: bundleId
  };

  const token = jwt.sign(payload, privateKey, {
    algorithm: 'ES256',
    keyid: keyId,
    header: {
      typ: 'JWT'
    }
  });

  return token;
}

module.exports = { generateAppStoreJwt };

生成 JWT 时常见的几个错误需要特别注意。第一是 iat 使用毫秒而不是秒,这会让苹果认为签发时间在未来,直接返回 401。第二是 aud 写成了 appstoreconnect 或者 appstoreconnect-v2,正确值只有 appstoreconnect-v1。第三是私钥文件被文本编辑器修改了格式,导致 PEM 头尾的换行丢失,签名时抛出 PEM_read_bio_PrivateKey 失败。第四是没有在 JWT 头部显式设置 kid,部分库能自动添加,但苹果推荐显式传入,避免混淆多个密钥。

订阅查询响应与状态码解析

拿到 JWT 后就可以发起查询请求。下面的代码使用 Node.js 内置的 fetch 函数访问生产环境端点,并通过 Authorization 头传递令牌。请求路径中的 transactionId 来自客户端购买成功后的回调,通常是数字字符串。

async function querySubscriptionStatus(transactionId) {
  const token = generateAppStoreJwt();
  const url = 'https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/' + transactionId;
  const response = await fetch(url, {
    method: 'GET',
    headers: {
      'Authorization': 'Bearer ' + token
    }
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error('Request failed with status ' + response.status + ': ' + errorBody);
  }

  const json = await response.json();
  const lastTransaction = json.data?.[0]?.lastTransactions?.[0];
  if (!lastTransaction) {
    throw new Error('Missing last transaction in response');
  }

  const signedInfo = lastTransaction.signedTransactionInfo;
  const payload = decodeJwsPayload(signedInfo);
  return payload;
}

function decodeJwsPayload(jws) {
  const parts = jws.split('.');
  if (parts.length !== 3) {
    throw new Error('Invalid JWS format');
  }
  const raw = Buffer.from(parts[1], 'base64url').toString('utf8');
  return JSON.parse(raw);
}

解码后的交易信息通常包含 autoRenewStatus、expirationIntent、gracePeriodExpired、offerType、environment 等字段。其中 autoRenewStatus 为 1 表示用户已开启自动续订,为 0 表示已关闭自动续订。需要注意 0 并不等于订阅已经失效,可能当前周期还在有效期内,只是下一个周期不会自动扣费。expirationIntent 的取值从 1 到 5,分别表示用户主动取消、账单错误、不同意涨价、产品在续订时不可用以及未知原因。这些字段组合起来才能判断用户的真实权益。

HTTP 状态码同样需要做精细处理。200 表示查询成功,可以直接读取响应体。400 表示请求参数有误,通常是 transactionId 格式不正确或路径拼错。401 表示 JWT 无效、已过期或者签名验签失败,需要检查密钥和载荷字段。403 表示当前密钥或 Bundle ID 没有权限查询该交易,常见于沙盒和生产环境混用。404 表示苹果服务器上不存在这个交易标识,可能是用户在退款后交易被清理。429 表示短时间请求过多,需要降低查询频率并做指数退避。5xx 是苹果服务器内部错误,可以稍后重试,但不能无限重试,否则会加剧限流。

沙盒环境与生产环境的切换注意事项

开发阶段一定要使用沙盒端点 https://api.storekit-sandbox.itunes.apple.com 进行测试,不能把生产密钥直接打到沙盒请求上。沙盒环境和生产环境使用相同的 JWT 载荷格式和 aud 值,但交易数据完全隔离。测试时需要在真机或模拟器上使用沙盒测试账号完成购买,客户端拿到的 transactionId 才能被沙盒端点识别。如果把生产环境的交易标识发给沙盒端点,会得到 404 或 401,这类错误经常被误判为签名问题。

除了手动轮询查询,苹果还提供了 App Store Server Notifications V2 供服务端接收订阅状态变更通知。推荐在正式环境中同时订阅 DID_RENEW、EXPIRED、REFUND、GRACE_PERIOD_EXPIRED 等事件类型,这样可以在续订成功、退款、宽限期结束等关键节点实时收到回调,减少无意义的轮询。服务端收到通知后,仍应通过 API 查询一次最终状态,因为通知只包含触发事件,不保证所有字段都已更新完成。

对订阅状态做本地缓存时,需要区分 Sandbox 和 Production 两个环境值。沙盒订阅的续订周期会被压缩,例如一个月的订阅在沙盒中可能几分钟就到期一次,因此不要在沙盒数据上测试与生产相同的缓存时长。生产环境建议将查询结果缓存 5 到 10 分钟,并在用户打开会员页面时做异步刷新。对于 429 限流响应,可以使用指数退避,第一次等待 5 秒,第二次等待 10 秒,最大等待不超过 60 秒,同时记录告警日志。

iOS内购App Store Server APIJWT生成修改时间:2026-09-20 12:30:31

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0920/59645.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。