rc-webhooks

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

RevenueCat Webhooks

RevenueCat Webhooks

You configure one endpoint. RevenueCat posts one normalized JSON event schema for every store. Your job on the server side is to verify the signature, deduplicate by
event.id
, and dispatch per event type.
你只需配置一个端点。RevenueCat会为每个应用商店推送统一格式的JSON事件。你在服务器端的工作是验证签名、通过
event.id
去重,并根据事件类型进行分发处理。

Phase 1: Discover

阶段一:准备确认

Confirm what you are wiring up before touching code.
  • You own a server side HTTPS endpoint that accepts POST with a JSON body.
  • You have the webhook secret from the RevenueCat dashboard under Integrations then Webhooks.
  • You have durable storage to record processed event IDs and entitlement state per
    app_user_id
    .
  • You understand that RevenueCat has already mapped products to entitlements, so you branch on
    event.type
    and read
    event.entitlement_ids
    . You do not maintain a product to entitlement table on your backend.
Every event has this outer shape:
json
{
  "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"
  }
}
在编写代码之前,先确认你需要对接的内容:
  • 你拥有一个支持HTTPS的服务器端端点,可接收带JSON请求体的POST请求。
  • 你已从RevenueCat控制台的“Integrations(集成)”→“Webhooks”中获取到webhook密钥。
  • 你拥有持久化存储,用于记录已处理的事件ID以及每个
    app_user_id
    对应的权限状态。
  • 你需要了解,RevenueCat已将产品映射到权限,因此你只需根据
    event.type
    分支处理,并读取
    event.entitlement_ids
    即可,无需在后端维护产品到权限的映射表。
每个事件都具有以下外层结构:
json
{
  "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: Plan

阶段二:处理规划

Pick the right action for each event type before you write the handler.
Event typeMeaningHandler action
INITIAL_PURCHASE
First paid transaction for this user and product.Grant entitlements in
entitlement_ids
.
RENEWAL
Subscription renewed, including resubscription after an
EXPIRATION
.
Grant or extend entitlements in
entitlement_ids
.
CANCELLATION
User turned off auto renew. Access continues until
expiration_at_ms
.
Schedule revocation at
expiration_at_ms
. Do not revoke now.
UNCANCELLATION
User re enabled auto renew before expiry.Cancel any scheduled revocation. Keep entitlements active.
EXPIRATION
Subscription actually ended.Revoke entitlements now.
BILLING_ISSUE
Payment failed. User may be in grace period or on hold.Flag the account. Do not revoke yet. RevenueCat sends
EXPIRATION
if recovery fails.
PRODUCT_CHANGE
User switched plan (upgrade, downgrade, or cross grade).Update the product on record. Entitlement state follows
entitlement_ids
.
SUBSCRIBER_ALIAS
Two app user IDs were merged into one identity.Merge your local records for the aliased IDs.
TRANSFER
A transaction moved from one app user ID to another.Move entitlements from the old ID to the new ID.
Key decisions baked into this table:
  • CANCELLATION
    is not an access change. It is an intent signal. Revoking now is a bug that deletes paid access the user still owns.
  • EXPIRATION
    is the access change. This is when you revoke.
  • Resubscription after an
    EXPIRATION
    fires
    RENEWAL
    , not
    INITIAL_PURCHASE
    . Your
    RENEWAL
    branch must be safe to run against a user whose entitlements are currently revoked, which means it must grant, not just extend.
  • BILLING_ISSUE
    is not revocation. Revoking on
    BILLING_ISSUE
    cuts off users who are still inside Google Play grace period or account hold.
在编写处理程序之前,为每种事件类型选择正确的处理动作:
Event typeMeaningHandler action
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已合并为一个身份。合并本地记录中这两个关联ID的信息。
TRANSFER
交易从一个应用用户ID转移到另一个。将权限从旧ID转移到新ID。
该表格中的关键决策点:
  • CANCELLATION
    并不代表访问权限变更,只是一个用户意图信号。立即撤销权限是错误的,会剥夺用户仍拥有的付费访问权。
  • EXPIRATION
    才是访问权限变更的触发点,此时需要撤销权限。
  • 订阅到期后重新订阅会触发
    RENEWAL
    事件,而非
    INITIAL_PURCHASE
    。你的
    RENEWAL
    处理分支必须能安全处理权限已被撤销的用户,这意味着它必须授予权限,而不仅仅是延长有效期。
  • BILLING_ISSUE
    不代表要撤销权限。在
    BILLING_ISSUE
    时撤销权限会切断仍处于Google Play宽限期或账户冻结状态的用户的访问。

Phase 3: Execute

阶段三:代码实现

Wire up a handler that verifies, deduplicates, and dispatches.
搭建一个处理程序,完成签名验证、去重和事件分发。

Verify the signature and parse

验证签名并解析事件

kotlin
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)
}
Return 2xx as soon as the event is persisted. If processing is slow, enqueue it and acknowledge. A slow handler causes retries and duplicate deliveries.
kotlin
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状态码,一旦事件被持久化就返回。如果处理过程较慢,可将事件加入队列并确认接收。处理程序过慢会导致重试和重复投递。

Deduplicate on event.id

通过event.id去重

RevenueCat can redeliver the same event.
event.id
is the idempotency key.
kotlin
suspend fun handleEvent(event: RcEvent) {
    if (processedEvents.insertIfAbsent(event.id)) {
        dispatch(event)
    }
    // Already processed: fall through, responder still returns 200.
}
insertIfAbsent
must be atomic in your store (a unique index on
event_id
plus an insert that swallows duplicate key errors works). Do all downstream writes in the same transaction as the event ID insert so a crash mid handler does not leave you with a marked but unapplied event.
RevenueCat可能会重新投递同一事件。
event.id
是幂等键。
kotlin
suspend fun handleEvent(event: RcEvent) {
    if (processedEvents.insertIfAbsent(event.id)) {
        dispatch(event)
    }
    // Already processed: fall through, responder still returns 200.
}
insertIfAbsent
在你的存储中必须是原子操作(比如在
event_id
上创建唯一索引,插入时忽略重复键错误即可)。将所有下游写入操作与事件ID插入放在同一事务中,这样处理程序中途崩溃时,不会出现事件已标记为处理但实际未执行的情况。

Dispatch per type

根据事件类型分发处理

kotlin
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
}
Notes that match the handbook:
  • grantEntitlements
    on
    RENEWAL
    must be idempotent and additive so a resubscribe after
    EXPIRATION
    restores access.
  • scheduleRevocation
    stores a pending job keyed by
    (app_user_id, entitlement_id)
    that fires at
    expiration_at_ms
    . If an
    UNCANCELLATION
    arrives first, cancel the job. If an
    EXPIRATION
    arrives first, let the
    EXPIRATION
    handler revoke and drop the pending job.
kotlin
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
}
与指南匹配的注意事项:
  • grantEntitlements
    on
    RENEWAL
    must be idempotent and additive so a resubscribe after
    EXPIRATION
    restores access.
  • scheduleRevocation
    stores a pending job keyed by
    (app_user_id, entitlement_id)
    that fires at
    expiration_at_ms
    . If an
    UNCANCELLATION
    arrives first, cancel the job. If an
    EXPIRATION
    arrives first, let the
    EXPIRATION
    handler revoke and drop the pending job.

CANCELLATION payload (access continues)

CANCELLATION事件 payload(用户仍可访问)

json
{
  "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"
  }
}
The user keeps
pro_access
until
1702592000000
. Schedule revocation for that timestamp.
json
{
  "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"
  }
}
用户在
1702592000000
之前仍拥有
pro_access
权限。需安排在该时间点撤销权限。

EXPIRATION payload (revoke now)

EXPIRATION事件 payload(立即撤销权限)

json
{
  "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"
  }
}
Revoke
pro_access
for
user_12345
as soon as you process this.
json
{
  "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
权限。

RENEWAL after expiry (resubscription)

到期后的RENEWAL事件(重新订阅)

When a lapsed user resubscribes, RevenueCat sends
RENEWAL
, not
INITIAL_PURCHASE
.
json
{
  "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"
  }
}
Your
RENEWAL
branch must grant entitlements, not assume they already exist. If you only extend an existing expiry, the resubscribed user stays locked out.
当过期用户重新订阅时,RevenueCat会发送
RENEWAL
事件,而非
INITIAL_PURCHASE
事件。
json
{
  "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
处理分支必须授予权限,不能假设权限已存在。如果仅延长现有有效期,重新订阅的用户仍会被锁定在外。

Verification Checklist

验证检查清单

  • Signature verification rejects requests with missing or wrong
    X-RevenueCat-Signature
    .
  • A replayed event with the same
    event.id
    is a no op and still returns 200.
  • CANCELLATION
    does not revoke access. The user retains entitlements until
    expiration_at_ms
    .
  • EXPIRATION
    revokes access for the IDs in
    entitlement_ids
    .
  • A
    RENEWAL
    arriving after an
    EXPIRATION
    restores access for the same
    app_user_id
    .
  • BILLING_ISSUE
    flags the account without revoking.
  • Handler returns 2xx within your retry window even when downstream work is async.
  • 签名验证会拒绝缺少或错误的
    X-RevenueCat-Signature
    的请求。
  • 具有相同
    event.id
    的重复事件不会执行任何操作,但仍返回200状态码。
  • CANCELLATION
    事件不会撤销访问权限,用户在
    expiration_at_ms
    之前仍保留权限。
  • EXPIRATION
    事件会撤销
    entitlement_ids
    中的权限。
  • EXPIRATION
    之后收到的
    RENEWAL
    事件会恢复同一
    app_user_id
    的访问权限。
  • BILLING_ISSUE
    事件会标记账户但不撤销权限。
  • 即使下游工作是异步的,处理程序也会在重试窗口内返回2xx状态码。

References

参考资料