rc-webhooks
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseRevenueCat 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 , and dispatch per event type.
event.id你只需配置一个端点。RevenueCat会为每个应用商店推送统一格式的JSON事件。你在服务器端的工作是验证签名、通过去重,并根据事件类型进行分发处理。
event.idPhase 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 and read
event.type. You do not maintain a product to entitlement table on your backend.event.entitlement_ids
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 type | Meaning | Handler action |
|---|---|---|
| First paid transaction for this user and product. | Grant entitlements in |
| Subscription renewed, including resubscription after an | Grant or extend entitlements in |
| User turned off auto renew. Access continues until | Schedule revocation at |
| User re enabled auto renew before expiry. | Cancel any scheduled revocation. Keep entitlements active. |
| Subscription actually ended. | Revoke entitlements now. |
| Payment failed. User may be in grace period or on hold. | Flag the account. Do not revoke yet. RevenueCat sends |
| User switched plan (upgrade, downgrade, or cross grade). | Update the product on record. Entitlement state follows |
| Two app user IDs were merged into one identity. | Merge your local records for the aliased IDs. |
| 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:
- is not an access change. It is an intent signal. Revoking now is a bug that deletes paid access the user still owns.
CANCELLATION - is the access change. This is when you revoke.
EXPIRATION - Resubscription after an fires
EXPIRATION, notRENEWAL. YourINITIAL_PURCHASEbranch must be safe to run against a user whose entitlements are currently revoked, which means it must grant, not just extend.RENEWAL - is not revocation. Revoking on
BILLING_ISSUEcuts off users who are still inside Google Play grace period or account hold.BILLING_ISSUE
在编写处理程序之前,为每种事件类型选择正确的处理动作:
| Event type | Meaning | Handler action |
|---|---|---|
| 用户首次购买该产品的付费交易。 | 授予 |
| 订阅已续订,包括在 | 授予或延长 |
| 用户关闭了自动续订。用户仍可继续访问直到 | 安排在 |
| 用户在到期前重新开启了自动续订。 | 取消已安排的撤销任务,保持权限处于激活状态。 |
| 订阅实际已到期。 | 立即撤销权限。 |
| 支付失败。用户可能处于宽限期或账户冻结状态。 | 标记该账户,但暂不撤销权限。如果恢复失败,RevenueCat会发送 |
| 用户切换了订阅计划(升级、降级或跨级切换)。 | 更新记录中的产品信息,权限状态以 |
| 两个应用用户ID已合并为一个身份。 | 合并本地记录中这两个关联ID的信息。 |
| 交易从一个应用用户ID转移到另一个。 | 将权限从旧ID转移到新ID。 |
该表格中的关键决策点:
- 并不代表访问权限变更,只是一个用户意图信号。立即撤销权限是错误的,会剥夺用户仍拥有的付费访问权。
CANCELLATION - 才是访问权限变更的触发点,此时需要撤销权限。
EXPIRATION - 订阅到期后重新订阅会触发事件,而非
RENEWAL。你的INITIAL_PURCHASE处理分支必须能安全处理权限已被撤销的用户,这意味着它必须授予权限,而不仅仅是延长有效期。RENEWAL - 不代表要撤销权限。在
BILLING_ISSUE时撤销权限会切断仍处于Google Play宽限期或账户冻结状态的用户的访问。BILLING_ISSUE
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. is the idempotency key.
event.idkotlin
suspend fun handleEvent(event: RcEvent) {
if (processedEvents.insertIfAbsent(event.id)) {
dispatch(event)
}
// Already processed: fall through, responder still returns 200.
}insertIfAbsentevent_idRevenueCat可能会重新投递同一事件。是幂等键。
event.idkotlin
suspend fun handleEvent(event: RcEvent) {
if (processedEvents.insertIfAbsent(event.id)) {
dispatch(event)
}
// Already processed: fall through, responder still returns 200.
}insertIfAbsentevent_idDispatch 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:
- on
grantEntitlementsmust be idempotent and additive so a resubscribe afterRENEWALrestores access.EXPIRATION - stores a pending job keyed by
scheduleRevocationthat fires at(app_user_id, entitlement_id). If anexpiration_at_msarrives first, cancel the job. If anUNCANCELLATIONarrives first, let theEXPIRATIONhandler revoke and drop the pending job.EXPIRATION
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
}与指南匹配的注意事项:
- on
grantEntitlementsmust be idempotent and additive so a resubscribe afterRENEWALrestores access.EXPIRATION - stores a pending job keyed by
scheduleRevocationthat fires at(app_user_id, entitlement_id). If anexpiration_at_msarrives first, cancel the job. If anUNCANCELLATIONarrives first, let theEXPIRATIONhandler revoke and drop the pending job.EXPIRATION
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 until . Schedule revocation for that timestamp.
pro_access1702592000000json
{
"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"
}
}用户在之前仍拥有权限。需安排在该时间点撤销权限。
1702592000000pro_accessEXPIRATION 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 for as soon as you process this.
pro_accessuser_12345json
{
"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_12345pro_accessRENEWAL after expiry (resubscription)
到期后的RENEWAL事件(重新订阅)
When a lapsed user resubscribes, RevenueCat sends , not .
RENEWALINITIAL_PURCHASEjson
{
"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 branch must grant entitlements, not assume they already exist. If you only extend an existing expiry, the resubscribed user stays locked out.
RENEWAL当过期用户重新订阅时,RevenueCat会发送事件,而非事件。
RENEWALINITIAL_PURCHASEjson
{
"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"
}
}你的处理分支必须授予权限,不能假设权限已存在。如果仅延长现有有效期,重新订阅的用户仍会被锁定在外。
RENEWALVerification Checklist
验证检查清单
- Signature verification rejects requests with missing or wrong .
X-RevenueCat-Signature - A replayed event with the same is a no op and still returns 200.
event.id - does not revoke access. The user retains entitlements until
CANCELLATION.expiration_at_ms - revokes access for the IDs in
EXPIRATION.entitlement_ids - A arriving after an
RENEWALrestores access for the sameEXPIRATION.app_user_id - flags the account without revoking.
BILLING_ISSUE - Handler returns 2xx within your retry window even when downstream work is async.
- 签名验证会拒绝缺少或错误的的请求。
X-RevenueCat-Signature - 具有相同的重复事件不会执行任何操作,但仍返回200状态码。
event.id - 事件不会撤销访问权限,用户在
CANCELLATION之前仍保留权限。expiration_at_ms - 事件会撤销
EXPIRATION中的权限。entitlement_ids - 之后收到的
EXPIRATION事件会恢复同一RENEWAL的访问权限。app_user_id - 事件会标记账户但不撤销权限。
BILLING_ISSUE - 即使下游工作是异步的,处理程序也会在重试窗口内返回2xx状态码。