Event types
Event body
Section titled “Event body”Every event has the same top-level fields, whichever channel it came from.
{ "id": "evt_3f0c...", "type": "subscription.renewed", "created_at": "2026-11-01T12:00:03.000Z", "app_user_id": "6f1c2a3b-4d5e-4f60-8a71-92b3c4d5e6f7", "platform": "ios", "environment": "production", "product_id": "pro_monthly", "transaction_id": "2000000789", "original_transaction_id": "2000000123", "purchased_at": "2026-11-01T12:00:00.000Z", "expires_at": "2026-12-01T12:00:00.000Z", "price_micros": 9990000, "currency": "USD", "ownership": "purchased", "data": { "store": "app_store", "notification_type": "DID_RENEW", "transaction_reason": "RENEWAL", "status": "active", "will_renew": true }}| Field | Meaning |
|---|---|
id |
Stable event id. Use it to deduplicate |
type |
One of the types below |
platform |
ios, android, web or test |
environment |
production, sandbox or test. See Environments |
price_micros |
Price times 1,000,000 in the original currency; 9.99 USD is 9990000 |
ownership |
purchased or family_shared |
data |
Channel details. data.store is app_store, google_play, stripe, ccbill or test_store |
| Type | When |
|---|---|
purchase |
A one-time product (consumable, non-consumable, non-renewing subscription) was recorded |
subscription.started |
A new subscription, or a resubscription after expiry |
subscription.renewed |
A renewal succeeded, including recovery after a failed charge (data.recovered) |
subscription.product_changed |
Upgrade (immediate, new transaction) or downgrade (data.change: downgrade or downgrade_canceled, data.effective: next_renewal, data.new_product_id) |
subscription.canceled |
Auto-renew turned off. Access continues until expires_at |
subscription.uncanceled |
Auto-renew turned back on |
subscription.billing_issue |
A charge failed. data.in_grace_period tells whether access continues |
subscription.grace_period_expired |
The grace period ended without a successful charge |
subscription.expired |
The subscription ended. data.expiration_reason: VOLUNTARY, BILLING_RETRY, PRICE_INCREASE, PRODUCT_NOT_FOR_SALE, and on other channels DEVELOPER or DISPUTE |
subscription.paused |
Android only. data.auto_resume_time |
refund |
Refunded. Revoke access |
refund.reversed |
Apple reversed a refund. Restore access |
refund.consumption_requested |
Apple asks for consumption information (data.deadline_at, data.default_refund_preference) |
revoked |
Family sharing access was revoked |
chargeback |
Stripe dispute opened. Access is not revoked; decide yourself |
chargeback.closed |
Dispute closed, data.outcome is won or lost. On lost a refund with data.reason: "chargeback_lost" follows |
ad.reward |
A rewarded ad was watched to the end. See Rewarded ads |
test |
Sent only by the test webhook endpoint. Verify, return 2xx, do nothing else |
Events exist only when Kasskeep knows the user. A transaction without an appAccountToken, such as an offer code redeemed outside the app, is stored silently and its events are sent once a user submits it.
Per channel
Section titled “Per channel”Not every channel produces every type.
| Type | App Store | Google Play | Stripe | Test store |
|---|---|---|---|---|
subscription.paused |
✓ | |||
refund.reversed |
✓ | ✓ | ||
refund.consumption_requested |
✓ | |||
revoked |
✓ | ✓ | ||
chargeback, chargeback.closed |
✓ |
On Android, data also carries purchase_token, subscription_state, base_plan_id, offer_id and notification_type (the RTDN name). On Stripe it carries notification_type, notification_id, subscription_id, invoice_id, stripe_status and checkout_session_id where they apply.
Handling events
Section titled “Handling events”- Verify the signature.
- Store the event id. If you have seen it, return 2xx and stop.
- Do not rely on order. Use
created_atand the fields in the event, or read the current state fromGET /v1/customers/:app_user_idwith your secret key. - Treat
sandboxandtestevents as non-revenue. - Return 2xx within 10 seconds. Do slow work after responding.