关于 rc-webhooks
rc-webhooks 是一项指导服务端处理 RevenueCat Webhook 的技能,涵盖签名验证、事件去重和按事件类型更新权益。
主要功能
- 01验证 Webhook 签名
- 02按 event.id 对事件去重
- 03按事件类型分发处理
- 04处理权益授予与撤销
适用场景
- 01构建 RevenueCat Webhook 端点
- 02避免重复处理事件
- 03根据订阅变化更新用户权益
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ revenuecat/ ai-toolkit --skill rc-webhooks安装后在 Claude Code 中输入/rc-webhooks确认已加载。
详细介绍
RevenueCat Webhooks
配置一个端点即可。RevenueCat 会为所有商店发送一种规范化的 JSON 事件架构。你需要在服务端验证签名、按 event.id 去重,并按事件类型进行分发。
Phase 1:发现
动手编写代码前,先确认你要接入的内容。
- 你负责一个服务端 HTTPS 端点,可接收带 JSON 请求体的 POST 请求。
- 你已从 RevenueCat 信息中心的 Integrations 下的 Webhooks 获取 Webhook 密钥。
- 你有持久化存储,用于记录已处理的事件 ID,以及每个
app_user_id的权益状态。 - 你了解 RevenueCat 已将产品映射到权益,因此只需根据
event.type分支处理,并读取event.entitlement_ids。你无需在后端维护产品到权益的映射表。
每个事件的外层结构如下:
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000001",
"type": "INITIAL_PURCHASE",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"period_type": "NORMAL",
"purchased_at_ms": 1700000000000,
"expiration_at_ms": 1702592000000,
"store": "PLAY_STORE",
"environment": "PRODUCTION",
"entitlement_ids": ["pro_access"],
"transaction_id": "GPA.1234-5678-9012-34567"
}
}
Phase 2:规划
编写处理程序前,为每种事件类型确定相应操作。
| 事件类型 | 含义 | 处理程序操作 |
|---|---|---|
INITIAL_PURCHASE | 此用户首次购买此产品。 | 授予 entitlement_ids 中的权益。 |
RENEWAL | 订阅已续订,包括 EXPIRATION 后重新订阅。 | 授予或延长 entitlement_ids 中的权益。 |
CANCELLATION | 用户已关闭自动续订。访问权限会持续到 expiration_at_ms。 | 在 expiration_at_ms 安排撤销。不要立即撤销。 |
UNCANCELLATION | 用户在到期前重新开启了自动续订。 | 取消所有已安排的撤销任务。保持权益有效。 |
EXPIRATION | 订阅已实际结束。 | 立即撤销权益。 |
BILLING_ISSUE | 付款失败。用户可能处于宽限期或账号保留期。 | 标记账号。暂不撤销。如果问题未解决,RevenueCat 会发送 EXPIRATION。 |
PRODUCT_CHANGE | 用户切换了方案(升级、降级或跨级变更)。 | 更新记录中的产品。权益状态以 entitlement_ids 为准。 |
SUBSCRIBER_ALIAS | 两个应用用户 ID 已合并为一个身份。 | 合并你本地对应的记录。 |
TRANSFER | 一笔交易从一个应用用户 ID 转移到了另一个。 | 将权益从旧 ID 转移到新 ID。 |
此表体现了以下关键决策:
CANCELLATION不会改变访问权限,它表示用户的意图。此时撤销属于错误操作,会剥夺用户仍有权享有的付费访问权限。EXPIRATION才会改变访问权限。应在此时撤销。EXPIRATION后重新订阅会触发RENEWAL,而非INITIAL_PURCHASE。因此,你的RENEWAL分支必须能够处理当前权益已被撤销的用户,也就是说,它必须授予权益,而不能只延长权益。BILLING_ISSUE不代表应撤销权益。在BILLING_ISSUE时撤销,会导致仍处于 Google Play 宽限期或账号保留期的用户失去访问权限。
Phase 3:执行
接入一个能够验证、去重并分发事件的处理程序。
验证签名并解析
post("/revenuecat/webhook") {
val body = call.receiveText()
val signature = call.request.headers["X-RevenueCat-Signature"]
if (!verifySignature(body, signature, webhookSecret)) {
call.respond(HttpStatusCode.Unauthorized); return@post
}
val event = Json.decodeFromString<RevenueCatEnvelope>(body).event
handleEvent(event)
call.respond(HttpStatusCode.OK)
}
事件持久化后立即返回 2xx。如果处理耗时较长,请将事件加入队列后确认接收。处理程序响应过慢会导致重试和重复投递。
按 event.id 去重
RevenueCat 可能会重复投递同一事件。event.id 是幂等键。
suspend fun handleEvent(event: RcEvent) {
if (processedEvents.insertIfAbsent(event.id)) {
dispatch(event)
}
// Already processed: fall through, responder still returns 200.
}
insertIfAbsent 在存储中必须是原子操作(对 event_id 建立唯一索引,并在插入时吞掉重复键错误即可)。所有下游写入都应与事件 ID 插入在同一事务中完成,避免处理程序中途崩溃后事件被标记为已处理、实际操作却未应用。
按类型分发
suspend fun dispatch(e: RcEvent) = when (e.type) {
"INITIAL_PURCHASE", "RENEWAL", "UNCANCELLATION" ->
db.grantEntitlements(e.appUserId, e.entitlementIds, e.expirationAtMs)
"CANCELLATION" ->
db.scheduleRevocation(e.appUserId, e.entitlementIds, e.expirationAtMs)
"EXPIRATION" ->
db.revokeEntitlements(e.appUserId, e.entitlementIds)
"BILLING_ISSUE" ->
db.flagBillingIssue(e.appUserId)
"PRODUCT_CHANGE" ->
db.updateProduct(e.appUserId, e.productId, e.entitlementIds)
"SUBSCRIBER_ALIAS", "TRANSFER" ->
db.mergeIdentity(e)
else -> Unit
}
符合手册要求的说明:
RENEWAL中的grantEntitlements必须具备幂等性并采用增量方式处理,以便在EXPIRATION后重新订阅时恢复访问权限。scheduleRevocation会存储一个以(app_user_id, entitlement_id)为键、并在expiration_at_ms执行的待处理任务。如果先收到UNCANCELLATION,则取消该任务。如果先收到EXPIRATION,则由EXPIRATION处理程序执行撤销并移除待处理任务。
取消载荷(访问权限仍然有效)
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000010",
"type": "CANCELLATION",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"purchased_at_ms": 1700000000000,
"expiration_at_ms": 1702592000000,
"entitlement_ids": ["pro_access"],
"store": "PLAY_STORE",
"environment": "PRODUCTION"
}
}
用户的 pro_access 会持续有效至 1702592000000。请安排在该时间戳撤销访问权限。
到期载荷(立即撤销)
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000011",
"type": "EXPIRATION",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"expiration_at_ms": 1702592000000,
"entitlement_ids": ["pro_access"],
"store": "PLAY_STORE",
"environment": "PRODUCTION"
}
}
处理此事件后,立即撤销 user_12345 的 pro_access。
到期后续订(重新订阅)
用户在订阅失效后重新订阅时,RevenueCat 会发送 RENEWAL,而不是 INITIAL_PURCHASE。
{
"api_version": "1.0",
"event": {
"id": "evt_01HABCXYZ0000000000000012",
"type": "RENEWAL",
"app_user_id": "user_12345",
"product_id": "premium_monthly",
"purchased_at_ms": 1705270400000,
"expiration_at_ms": 1707862400000,
"entitlement_ids": ["pro_access"],
"store": "PLAY_STORE",
"environment": "PRODUCTION"
}
}
你的 RENEWAL 分支必须授予权益,不能假设权益已经存在。如果你只延长已有权益的到期时间,重新订阅的用户仍会无法访问。
验证清单
- 签名验证会拒绝缺少或错误的
X-RevenueCat-Signature请求。 - 对具有相同
event.id的事件进行重放时,不执行任何操作,但仍返回 200。 CANCELLATION不会撤销访问权限。用户在expiration_at_ms之前仍保留其权益。EXPIRATION会撤销entitlement_ids中各 ID 对应的访问权限。EXPIRATION之后收到RENEWAL时,会为相同的app_user_id恢复访问权限。BILLING_ISSUE会标记账户,但不会撤销访问权限。- 即使下游工作以异步方式执行,处理程序也会在你的重试时限内返回 2xx。
参考资料
评论
支持 Markdown