mcpField
R
rc-webhooks

rc-webhooks

github.com/revenuecat开发76671 次安装1 天前更新
代码仓库

关于 rc-webhooks

rc-webhooks 是一项指导服务端处理 RevenueCat Webhook 的技能,涵盖签名验证、事件去重和按事件类型更新权益。

主要功能

  1. 01验证 Webhook 签名
  2. 02按 event.id 对事件去重
  3. 03按事件类型分发处理
  4. 04处理权益授予与撤销

适用场景

  1. 01构建 RevenueCat Webhook 端点
  2. 02避免重复处理事件
  3. 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