Google Play IAP 支付完整对接流程
从 Play Console 商品、服务账号和 Android 客户端,到服务端 verify、Cloud Pub/Sub RTDN webhook 与内部测试的完整接入指南。
文章目录
本文记录 Google Play IAP 的完整接入流程。前提是 Google Play Console 已创建应用,目标是打通下面这条链路:
Play Console 配置商品
↓
Android App 查询商品并发起支付
↓
Google Play 返回 purchaseToken
↓
App 将 purchaseToken、productId、packageName 发给服务端
↓
服务端调用 Google Play Developer API 验单
↓
服务端幂等发放权益,并 acknowledge 或 consume
↓
Google Play → Cloud Pub/Sub → webhook 同步续费、退款等后续状态
客户端返回的购买结果只能作为验单参数,不能直接作为发放权益的依据。商品 ID、订单状态、归属用户、是否重复发放和最终权益都必须由服务端确认。
一、先确定商品类型
Google Play 的数字商品主要分为两类:
| 类型 | 适用场景 | 支付完成后的处理 |
|---|---|---|
| 一次性商品 | 点数、次数包、永久功能 | 可重复购买的商品执行 consume;永久权益执行 acknowledge |
| 订阅 | 月卡、季卡、年卡 | 服务端验证订阅状态并执行 acknowledge,后续通过 RTDN 同步续费、暂停、取消和退款 |
consume 会同时确认购买并允许同一账号再次购买该商品。不要对永久解锁类商品执行 consume,否则用户可以重复购买;也不要把可重复购买的消耗品仅做 acknowledge,否则该账号不能再次购买同一商品。
以下示例以:
- 包名:
com.example.app - 一次性商品 ID:
coins_100 - 订阅 ID:
vip_monthly - 服务端接口:
POST /api/pay/google/verify - webhook:
POST https://api.example.com/api/pay/google/webhook
为例。接入时全部替换为当前项目的真实值。
二、在 Play Console 配置商品
1. 完成收款前置配置
先在 Play Console 检查以下项目:
- 应用包名必须与 Android 工程的
applicationId完全一致。 - 完成开发者账号、付款资料和商家资料配置。
- 至少上传一个带 Play Billing Library 的 AAB 到内部测试轨道。
- 完成 Play Console 要求的应用内容、目标受众、数据安全和国家/地区等必要配置。
如果“产品”或“订阅”创建入口不可用,通常是还没有上传 AAB、付款资料未完成,或当前账号没有管理商品的权限。
2. 创建一次性商品
进入当前应用,打开“获利/Monetize → 产品/Products → 一次性商品/One-time products”。不同语言和新版 Console 的菜单名称可能略有差异。
创建商品时填写:
| 字段 | 示例 | 注意事项 |
|---|---|---|
| Product ID | coins_100 | 发布后不要随意修改;必须与客户端、服务端数据库一致 |
| 名称 | 100 Coins | 用户会在购买页看到 |
| 描述 | Get 100 coins | 补齐所有目标语言 |
| Purchase option | Buy | 设置可购买的地区和价格 |
| 状态 | Active | 未激活的商品客户端查不到 |
Google Play Console 正在逐步使用新版一次性商品模型,商品下可能还需要创建并激活 purchase option/offer。最终要确认商品本身及购买选项都处于有效状态。官方将可重复购买和永久权益都归在一次性商品中,是否可重复购买由应用在支付后选择 consume 还是 acknowledge 决定。参见 管理商品目录。
3. 创建订阅
进入“获利/Monetize → 产品/Products → 订阅/Subscriptions”,创建 vip_monthly,然后继续创建基础方案:
- 选择自动续订或预付费;常规会员一般使用自动续订。
- 设置结算周期,例如
P1M。 - 选择可售国家/地区并设置价格。
- 如有试用或首购优惠,再在基础方案下创建 offer。
- 激活基础方案和订阅。
客户端购买订阅时不仅要匹配 product ID,还可能需要使用查询商品后返回的 offer token。服务端应保存内部套餐与 Google productId 的映射,不要相信客户端传入的价格或会员天数。
三、配置 Google Play Developer API
服务端需要通过 Android Publisher API 查询真实购买状态。官方流程包含 Google Cloud 和 Play Console 两部分,详见 Google Play Developer API 入门。
1. Google Cloud Console
- 创建或选择一个 Google Cloud 项目。
- 打开“API 和服务 → 库”。
- 搜索并启用 Google Play Android Developer API。
- 打开“IAM 和管理 → 服务账号”,创建一个仅供支付服务使用的服务账号,例如
google-play-iap-server。 - 如果服务部署在 Google Cloud,优先使用绑定的服务账号或 Workload Identity,避免下载长期 JSON 密钥。
- 如果现有部署只能使用密钥,在服务账号的“密钥”中创建 JSON 密钥,将
client_email和private_key保存到服务端密钥系统,绝不能放进 App、前端代码或 Git。
2. Google Play Console
- 打开“用户和权限/Users and permissions”。
- 邀请刚创建的服务账号邮箱,例如
google-play-iap-server@PROJECT_ID.iam.gserviceaccount.com。 - 将权限限制到目标应用。
- 至少授予 查看财务数据、订单和取消调查回复、管理订单和订阅。实际名称会随 Console 语言略有变化。
- 保存邀请,等待权限生效。
旧教程经常要求在“API access”中关联 Cloud 项目;官方当前说明已不再要求为了 Developer API 访问而关联开发者账号和 Cloud 项目。服务账号仍需在 Play Console 的“用户和权限”中被邀请并获得应用权限。
服务端配置示例:
google: {
androidPublisher: {
client_email: process.env.GOOGLE_PLAY_CLIENT_EMAIL,
private_key: process.env.GOOGLE_PLAY_PRIVATE_KEY
}
}
环境变量中的私钥通常把换行保存成 \\n,使用前需要恢复:
const privateKey = process.env.GOOGLE_PLAY_PRIVATE_KEY.replace(/\\n/g, '\n')
四、Android 客户端支付流程
客户端使用 Google Play Billing Library,核心步骤如下:
- 创建并连接
BillingClient。 - 用
queryProductDetailsAsync查询商品,页面展示 Google 返回的本地化名称和价格。 - 调用
launchBillingFlow发起购买。 - 在
PurchasesUpdatedListener中处理购买结果。 - 当购买状态为
PURCHASED,把purchaseToken、productId、packageName和内部预订单 ID 发给服务端。 - 只有服务端 verify 成功后,客户端才刷新权益状态。
- App 每次启动或 BillingClient 重连后,再用
queryPurchasesAsync恢复未处理的购买,并重试 verify。
提交给服务端的请求可以是:
{
"logVipId": "internal-order-id",
"packageName": "com.example.app",
"productId": "vip_monthly",
"purchaseToken": "google-play-purchase-token"
}
不要上传或依赖客户端显示的 orderId、金额、币种、会员天数。purchaseToken 才是调用 Developer API 验单的关键凭证;业务权益应由服务端按可信的 productId → 套餐 映射决定。
如果购买状态为 PENDING,页面应显示“处理中”,不要发权益,也不要 consume/acknowledge。待状态变为 PURCHASED 后再走 verify。
五、服务端 verify 实现
下面的结构参考 det-server/src/api/pay/google,保留它的核心设计:服务账号换取 access token,按商品类型查询 Google,校验后幂等发权益,最后确认或消费购买。
1. 获取 Android Publisher access token
import jwt from 'jsonwebtoken'
const scope = 'https://www.googleapis.com/auth/androidpublisher'
async function accessTokenGet() {
const now = Math.floor(Date.now() / 1000)
const assertion = jwt.sign(
{
iss: process.env.GOOGLE_PLAY_CLIENT_EMAIL,
scope,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600
},
process.env.GOOGLE_PLAY_PRIVATE_KEY.replace(/\\n/g, '\n'),
{ algorithm: 'RS256' }
)
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion
})
})
const result = await response.json()
if (!response.ok || !result.access_token) {
throw new Error(`Google access token 获取失败: ${result.error_description || result.error}`)
}
return result.access_token
}
生产环境应缓存 access token 到过期前几分钟,避免每次验单都请求 OAuth token。也可以直接使用官方 Google Auth 库管理签名、换取和缓存 token。
2. 查询一次性商品
参考实现使用 purchases.products.get:
async function productGet({ packageName, productId, purchaseToken }) {
const accessToken = await accessTokenGet()
const url =
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` +
`${encodeURIComponent(packageName)}/purchases/products/` +
`${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}`
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` }
})
const data = await response.json()
if (!response.ok) throw new Error(data.error?.message || 'Google Play 验单失败')
return data
}
发权益前至少检查:
- 请求中的
packageName必须等于服务端为当前 App 固定配置的包名,不能任由客户端指定任意应用。 - Google 返回的商品必须与内部订单的
productId一致。 purchaseState === 0,即已经购买;pending 或 canceled 都不能发权益。purchaseToken + orderId没有被其他订单或用户使用。- 一次性消耗品的
consumptionState !== 1。
3. 查询订阅
订阅使用 purchases.subscriptionsv2.get:
async function subscriptionGet({ packageName, purchaseToken }) {
const accessToken = await accessTokenGet()
const url =
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` +
`${encodeURIComponent(packageName)}/purchases/subscriptionsv2/tokens/` +
`${encodeURIComponent(purchaseToken)}`
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` }
})
const data = await response.json()
if (!response.ok) throw new Error(data.error?.message || 'Google Play 订阅查询失败')
return data
}
从 lineItems 中找到对应 productId,并检查 subscriptionState、expiryTime 和套餐关系。ACTIVE、仍在有效期内的 CANCELED,以及业务允许的宽限期状态可能仍有权益;EXPIRED、PENDING、PAUSED 等状态不能简单当成已支付。
4. 幂等发放权益
推荐先建立内部订单,再发起 Play 支付。订单至少保存:
| 字段 | 用途 |
|---|---|
id | 内部订单 ID |
uid | 业务用户 |
source | 固定为 google |
productId | 对应 Play 商品与内部套餐 |
purchaseToken | Google 购买凭证,建议加唯一索引 |
orderId | Google 订单号 |
status | pending、paid、refunded 等 |
payTime、expiryTime | 支付和权益有效期 |
参考实现用 ${purchaseToken}|${orderId} 作为 sessionId,并在发权益前查询是否已存在成功订单。这一检查最好由数据库唯一索引和事务兜底,避免客户端重试、并发 verify 或 Pub/Sub 重投造成重复发放。
建议事务顺序是:
- 锁定内部订单。
- 检查 token 是否已绑定其他用户。
- 写入成功订单和权益变更。
- 提交事务。
- 调用 Google consume/acknowledge;失败时进入可靠重试队列。
不要为了等待 Google API 而长时间持有数据库事务。发权益与确认 Google 购买之间需要可恢复状态,确保进程在任一步骤崩溃后都能安全重试。
5. consume 与 acknowledge
一次性消耗品调用:
POST https://androidpublisher.googleapis.com/androidpublisher/v3/
applications/{packageName}/purchases/products/{productId}/tokens/{purchaseToken}:consume
一次性永久权益调用 products acknowledge,订阅调用 subscriptions acknowledge。请求 body 可包含服务端生成的 developerPayload,也可传空 JSON。
POST https://androidpublisher.googleapis.com/androidpublisher/v3/
applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{purchaseToken}:acknowledge
Google 要求新购买在规定期限内得到确认,否则会自动退款并撤销购买。参考目录目前能看到一次性商品 consume,但没有看到订阅 acknowledge;正式接入时必须补齐。只有首次购买需要 acknowledge,续费不需要重复 acknowledge。支付处理原则可参考 一次性商品生命周期。
6. verify 接口的完整判断顺序
校验登录用户和请求字段
→ 校验 packageName 是服务端允许的固定包名
→ 根据内部订单读取可信 productId、商品类型和权益
→ 请求 Google Developer API
→ 校验购买状态、productId、有效期
→ 校验 purchaseToken/orderId 未绑定其他用户
→ 数据库事务内幂等发权益并更新订单
→ consume 消耗品,或 acknowledge 永久商品/新订阅
→ 返回最新权益
接口应该允许同一用户使用同一 token 重试并返回相同结果,但禁止同一 token 绑定到其他用户。
六、配置 RTDN webhook
RTDN(Real-time developer notifications)不是 Google Play 直接请求业务 webhook,而是:
Google Play → Pub/Sub Topic → Push Subscription → HTTPS webhook
RTDN 只表示购买状态发生变化。收到消息后仍必须用其中的 purchaseToken 调用 Google Play Developer API 查询完整、可信的最新状态,不能只按通知类型直接修改权益。参见 RTDN 参考。
1. 在 Google Cloud 创建 Pub/Sub topic
- 选择用于 Play 支付的 Google Cloud 项目。
- 如果尚未启用,启用 Cloud Pub/Sub API。
- 打开“Pub/Sub → Topics”,点击“Create topic”。
- Topic ID 填写
google-play-rtdn。 - 创建后得到完整名称:
projects/PROJECT_ID/topics/google-play-rtdn
- 打开该 topic 的“Permissions”。
- 添加 principal:
google-play-developer-notifications@system.gserviceaccount.com
- 授予 Pub/Sub Publisher(
roles/pubsub.publisher)并保存。
这一步是允许 Google Play 往 topic 发布消息,不能把它误配成自己创建的 Developer API 服务账号。官方配置说明见 准备接入 Play Billing。
2. 创建 Push subscription
在 topic 页面点击“Create subscription”:
- Subscription ID:
google-play-rtdn-push。 - Delivery type:Push。
- Endpoint URL:
https://api.example.com/api/pay/google/webhook。 - Endpoint 必须是公网可访问的 HTTPS 地址,并快速返回 2xx。
- 配置合理的重试策略和 dead-letter topic,避免持续失败的消息永久丢失。
- 建议启用 authenticated push,而不是暴露一个完全匿名的公网入口。
关于 push subscription 的创建字段可参考 创建 Push subscription。
3. 为 Push 开启 OIDC 身份认证
创建专用服务账号,例如:
pubsub-push@PROJECT_ID.iam.gserviceaccount.com
配置 subscription 时勾选“Enable authentication”,选择该服务账号,并把 audience 设置为固定值,例如 webhook URL:
https://api.example.com/api/pay/google/webhook
同时确保:
- 创建 subscription 的操作者能 impersonate 这个 push 服务账号,通常需要
iam.serviceAccounts.actAs。 - Pub/Sub service agent
service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com对 push 服务账号拥有 Service Account Token Creator。 - webhook 验证
Authorization: Bearer <OIDC JWT>的签名、iss、aud、exp、email_verified,并确认email正是上面的 push 服务账号。
官方的完整权限和 JWT 校验要求见 Pub/Sub authenticated push。还可以在 URL 中附加一段独立随机验证 token 作为第二层校验,但它不能替代 OIDC JWT。
参考 det-server 的 webhook 代码目前只解析 body,没有看到 OIDC JWT 校验。生产环境应在路由中间件或网关层补齐认证,否则任何人都可以伪造 Pub/Sub envelope 请求该公网接口。
4. 在 Play Console 绑定 topic
进入当前应用的“获利设置/Monetization setup”或实时开发者通知配置:
- 找到 Real-time developer notifications。
- Topic name 填完整资源名:
projects/PROJECT_ID/topics/google-play-rtdn
- 根据业务勾选订阅、一次性商品和 voided purchases 通知。
- 保存配置。
- 点击 Send test notification。
测试消息应依次出现在 Pub/Sub 指标、push subscription 投递记录和服务端 webhook 日志中。测试消息只验证通知管道,不代表真实支付成功。
5. webhook 请求格式
Pub/Sub 默认以 wrapped JSON 发送:
{
"message": {
"messageId": "1234567890",
"publishTime": "2026-08-27T10:00:00Z",
"data": "base64-encoded-rtdn-json"
},
"subscription": "projects/PROJECT_ID/subscriptions/google-play-rtdn-push"
}
解码 message.data 后才是 RTDN:
function notificationParse(body) {
const { message = {} } = body || {}
if (!message.messageId || !message.data) {
throw new Error('Google RTDN messageId 和 data 不能为空')
}
const data = JSON.parse(
Buffer.from(message.data, 'base64').toString('utf8')
)
return { messageId: String(message.messageId), ...data }
}
解码后的对象只会出现某一种主要通知体,例如 subscriptionNotification、oneTimeProductNotification、voidedPurchaseNotification 或 testNotification。
6. webhook 处理规则
推荐处理步骤:
- 验证 Pub/Sub OIDC JWT。
- 校验 envelope 并 Base64 解码。
- 以
messageId做接收幂等,插入 webhook inbox 表。 - 尽快返回 2xx,再由队列或后台任务处理;或确保同步处理能在 push 超时前完成。
- 根据
purchaseToken调 Developer API 查询最新状态。 - 在事务中幂等更新订单和权益。
- 标记 inbox 已处理;失败则保留错误并重试。
只在成功接收并可靠保存消息后返回 2xx。返回非 2xx 或超时会触发 Pub/Sub 重投,所以重复消息属于正常情况。
参考实现已经用 google_webhook.messageId 去重,并针对部分订阅事件和 voidedPurchaseNotification 调 Google API 后续处理;正式上线还应:
- 给
messageId加数据库唯一索引,避免并发重复插入。 - 补齐 OIDC 验证。
- 显式处理
testNotification。 - 补齐
oneTimeProductNotification,尤其是 pending 转 purchased/canceled。 - 不要只硬编码少数通知数字;维护完整枚举并记录未知类型。
- 将耗时操作移到队列,保证 push endpoint 快速响应。
- 退款/撤销权益必须幂等,并保留人工审计记录。
七、如何测试支付
1. 添加 License testers
进入 Play Console 的“设置/Setup → License testing”,添加用于测试的 Google 账号,并保存许可响应配置。
License tester 的作用是显示 Google 提供的测试支付方式,不产生真实扣款,还能模拟成功、拒绝等结果。仅仅把账号加到内部测试名单并不等于 License tester;这两个名单建议都配置。
2. 创建内部测试版本
- 使用与 Play Console 应用完全一致的包名构建 release AAB。
- versionCode 必须高于已上传版本。
- 使用 Play App Signing 对应流程上传到“测试 → 内部测试”。
- 创建 release,完成检查并发布到内部测试轨道。
- 在“Testers”中创建邮件列表或 Google Group,加入测试账号。
- 复制内部测试的 opt-in link 发给测试人员。
- 测试账号打开链接并选择加入测试,然后从该页面跳转到 Google Play 安装 App。
内部测试版本发布后可能需要一段时间才对账号可见。测试账号必须接受 opt-in,且设备 Google Play 当前使用的账号必须正确。
3. 确认实际付款账号
一台设备登录多个 Google 账号时,购买账号通常与“从 Play 商店下载该 App 的账号”相关。测试前建议:
- 在 Play 商店切换到 License tester 账号。
- 用该账号打开 opt-in link 并安装 App。
- 在支付弹窗中展开账号信息,确认显示的是测试账号。
- 确认弹窗明确标识为测试购买,并出现测试卡。
如果弹窗出现真实银行卡或真实金额但没有测试提示,立即取消,重新检查 License tester 和下载账号。官方说明与完整测试场景见 测试 Google Play Billing 集成。
4. 建议测试用例
| 场景 | 预期结果 |
|---|---|
| 一次性商品成功 | verify 成功、权益发放一次、商品被 consume 后可再次购买 |
| 订阅首次购买 | verify 成功、权益到期时间正确、购买被 acknowledge |
| 客户端重复 verify | 返回同一结果,不重复发权益 |
| 同一 token 换用户提交 | 服务端拒绝 |
| pending purchase | 不发权益,后续转为 purchased 后可恢复 |
| 测试卡拒绝 | 不创建成功订单、不发权益 |
| App 杀进程后恢复 | queryPurchasesAsync 找到购买并重新 verify |
| 订阅自动续费 | 收到 RTDN,查询 Google 后延长权益一次 |
| 取消订阅 | 关闭自动续费,但在已付款有效期内仍保留权益 |
| 退款或撤销 | 收到 voided/相关通知,幂等撤销对应权益 |
| webhook 重复投递 | messageId/交易唯一键阻止重复处理 |
| webhook 签名无效 | 返回 401/403,不写业务数据 |
License tester 的订阅周期会被加速,例如常见月订阅可能数分钟续费一次,因此可以在较短时间内验证续费、宽限期、账号保留和到期。具体测试周期以官方测试页面的最新表格为准。
5. 测试 RTDN
按三层分别测试,排错会更快:
- 管道测试:Play Console 点击 Send test notification,确认 webhook 收到
testNotification。 - 真实状态测试:测试账号购买、取消和等待续费,确认收到对应 RTDN,并二次查询 Developer API。
- 重投测试:让 webhook 临时返回 500,确认 Pub/Sub 会重试;恢复后消息只处理一次。
同时检查 Cloud Console 中 subscription 的 push 成功率、未确认消息数和 dead-letter topic,并为连续失败设置告警。
八、上线前检查清单
- Play Console 商品、purchase option、订阅基础方案和 offer 都已激活。
- 客户端包名、签名、Billing Library 和商品 ID 正确。
- Developer API 已启用,服务账号只拥有目标应用所需权限。
- 服务端固定校验允许的 package name,不信任客户端价格和权益参数。
- verify 根据商品类型查询正确 API,并校验状态、productId、token 和有效期。
- 数据库对 purchase token/交易键和 webhook message ID 有唯一约束。
- 权益发放、续费和退款都支持幂等重试。
- 消耗品使用 consume,永久商品和新订阅使用 acknowledge。
- App 会恢复未处理购买,pending 状态不会提前发权益。
- Pub/Sub topic 已授权 Google Play 系统账号发布消息。
- Push subscription 使用 HTTPS、OIDC JWT 校验、重试策略和 dead-letter topic。
- Play Console 已绑定完整 topic 名并成功发送测试通知。
- webhook 收到 RTDN 后始终再查 Developer API,不直接相信通知内容。
- 已用 License tester 和内部测试安装链接跑完成功、失败、续费、退款及重复投递测试。
- 密钥只保存在服务端密钥系统,日志不会输出 purchase token、私钥或完整用户隐私数据。
完成以上步骤后,客户端同步 verify 负责让用户尽快拿到权益,RTDN 负责修正续费、取消、退款等客户端不一定在线时发生的状态变化,两条链路共同构成可靠的 Google Play IAP 支付系统。