Loading...
Loading...
Use this skill when consuming RevenueCat webhooks on your backend. Covers the normalized event schema, the full event type list, idempotency via the event id field, and the correct handling for CANCELLATION versus EXPIRATION versus RENEWAL.
npx skill4agent add revenuecat/ai-toolkit rc-webhooksevent.idapp_user_idevent.typeevent.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"
}
}| 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. |
CANCELLATIONEXPIRATIONEXPIRATIONRENEWALINITIAL_PURCHASERENEWALBILLING_ISSUEBILLING_ISSUEpost("/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)
}event.idsuspend fun handleEvent(event: RcEvent) {
if (processedEvents.insertIfAbsent(event.id)) {
dispatch(event)
}
// Already processed: fall through, responder still returns 200.
}insertIfAbsentevent_idsuspend 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
}grantEntitlementsRENEWALEXPIRATIONscheduleRevocation(app_user_id, entitlement_id)expiration_at_msUNCANCELLATIONEXPIRATIONEXPIRATION{
"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_access1702592000000{
"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"
}
}pro_accessuser_12345RENEWALINITIAL_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"
}
}RENEWALX-RevenueCat-Signatureevent.idCANCELLATIONexpiration_at_msEXPIRATIONentitlement_idsRENEWALEXPIRATIONapp_user_idBILLING_ISSUE