You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

调用App Store Connect外部购买报告API返回401错误求助

调用App Store Connect外部购买报告API返回401错误求助

看起来你在对接Apple ExternalPurchase报告API时碰到了401认证错误,这种情况确实挠头——明明JWT在jwt.io能验证通过,但API就是不认。我来帮你梳理几个高概率的排查方向,结合你的代码逐一分析:

1. 私钥与权限的基础检查

这是401错误最常见的根源,先把这些基础项确认清楚:

  • 密钥文件读取是否正确:你用fs.readFileSync("AuthKey_xxx.p8", "utf8")读取私钥,要确保文件路径绝对正确——如果密钥文件不在项目根目录,建议用path.resolve(__dirname, "AuthKey_xxx.p8")生成绝对路径,避免相对路径导致的读取失败。另外打开p8文件确认内容完整,没有多余的换行、空格或注释。
  • 密钥与Issuer ID是否匹配:检查APPLE_KEY_IDAPPLE_ISSUER_ID有没有复制错误,特别是Issuer ID的连字符,别少打或多打。还要去App Store Connect的「Users and Access」→「Keys」页面,确认这个AuthKey是否关联了正确的Issuer ID,且状态是「Active」。
  • 密钥权限是否足够:这个AuthKey必须开启App Store Connect API的权限,且关联的用户要有「Sales and Reports」或「App Store Connect API」的访问权限——权限不足的话,就算JWT签名正确,Apple也会返回401。

2. JWT生成的细节校验

你的generateAppleJwt函数逻辑看起来没问题,但几个细节可能藏坑:

  • 服务器时钟同步问题:Apple对JWT的iat(签发时间)和exp(过期时间)校验很严格,如果你的服务器时钟和Apple的时间差过大(比如超过1分钟),会直接导致JWT无效。建议用NTP同步服务器时钟,或者手动校准。
  • JWT字段的精确匹配:用jwt.io解析你实际生成的JWT,逐一核对:
    • iss必须完全等于你的Issuer ID
    • audappstoreconnect-v1(你的代码里是对的)
    • iatexp必须是秒级时间戳,且exp不能超过iat+300(5分钟,你的代码符合要求)
  • 冗余的Header配置:你在header里手动指定了typ: "JWT",其实jsonwebtoken库会自动添加这个字段,虽然不是致命问题,但可以尝试去掉这个配置,避免潜在的大小写或格式冲突。

3. API请求的端点与参数问题

  • 沙箱/生产环境是否对应:你用的是沙箱端点https://api.storekit-sandbox.apple.com/externalPurchase/v1/reports,如果是测试沙箱购买没问题,但如果是生产环境要换成https://api.storekit.itunes.apple.com/externalPurchase/v1/reports。同时确认appAppleIdbundleId是对应环境的正确值。
  • 请求头的冗余配置:你加了Accept-Encoding: identity,这个可能会干扰Apple服务器的响应处理,建议去掉这个请求头,使用默认的gzip编码即可。
  • 请求参数格式校验:检查applePayload的字段:
    • purchaseAmount.amount你用了toFixed(2)转成字符串,Apple的API接受数字或字符串,但要确保精度正确(比如Stripe的total是分,除以100后是正确的金额)。
    • externalPurchaseId必须是Apple生成的外部购买ID,而不是Stripe的订阅ID——如果传错了ID,虽然可能返回400,但也有可能触发权限类的401,这点要注意。

4. 排除Webhook上下文的干扰

你的代码是在Stripe Webhook里触发Apple API请求,建议先写一个独立的测试函数,直接调用Apple API,排除Webhook的干扰:

const path = require("path");
const jwt = require("jsonwebtoken");
const fs = require("fs");

const APPLE_KEY_ID = "xxx";
const APPLE_ISSUER_ID = "xxx-xxx-xxx-xxx-xxx";
const APPLE_PRIVATE_KEY = fs.readFileSync(path.resolve(__dirname, "AuthKey_xxx.p8"), "utf8");
const APPLE_AUDIENCE = "appstoreconnect-v1";

function generateAppleJwt() {
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iss: APPLE_ISSUER_ID,
    iat: now,
    exp: now + (5 * 60),
    aud: APPLE_AUDIENCE
  };
  return jwt.sign(payload, APPLE_PRIVATE_KEY, {
    algorithm: "ES256",
    header: {
      alg: "ES256",
      kid: APPLE_KEY_ID
    }
  });
}

async function testAppleAPI() {
  const token = generateAppleJwt();
  console.log("Generated JWT:", token); // 复制这个JWT到jwt.io再校验一遍

  const applePayload = {
    appAppleId: "xxx",
    bundleId: "com.xxx.xxx.test",
    externalPurchaseId: "替换为真实的Apple外部购买ID",
    purchaseTime: new Date().toISOString(),
    purchaseAmount: {
      amount: "9.99",
      currencyCode: "USD"
    },
    purchaseLocation: {
      isoCountryCode: "US"
    }
  };

  try {
    const response = await fetch(
      "https://api.storekit-sandbox.apple.com/externalPurchase/v1/reports",
      {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify(applePayload)
      }
    );

    const responseText = await response.text();
    console.log("Response Status:", response.status);
    console.log("Response Body:", responseText);
  } catch (err) {
    console.error("Request Error:", err);
  }
}

testAppleAPI();

这个独立测试会返回更详细的错误信息(比如invalid signatureexpired tokeninvalid issuer等),比Webhook里的模糊401更有参考价值。

先从这些方向排查,大概率能定位到问题。如果还是不行,可以把独立测试返回的具体错误信息贴出来,再进一步分析。

内容来源于stack exchange

火山引擎 最新活动