Subscription lifecycle
How purchases, renewals, cancellations, refunds and revocations change a person's access and which webhooks fire.
A subscription moves from purchase through renewals to its end. Voidhash learns each change from the store or payment source, updates the person's entitlements, and sends a webhook when the change matters to your backend.
Gate access on perks, not on subscription status. A perk stays active in every state that still entitles the person, so one check covers trials, grace periods and pending cancellations.
How Voidhash learns about changes
| Provider | Source of changes |
|---|---|
| App Store | The SDK reports each transaction; App Store Server Notifications report later changes. |
| Google Play | The SDK reports each purchase; Real-time developer notifications (RTDN) report later changes. |
| Stripe | Stripe events such as invoice.paid, customer.subscription.updated, customer.subscription.deleted and charge.refunded, each read back from Stripe. |
| Payment source | Your provider endpoint reports transactions and subscription states. These events carry verified: false. |
Voidhash processes each store event once: a repeat sends no second webhook, and a late event can't move a subscription back to an older state. An event for an unmapped store product is applied once you map it, if you do so within a year. See Unmapped purchases.
States
A refund, revocation or immediate cancellation ends a subscription at once (dashed).
Statuses
Voidhash stores two statuses, which webhooks report in status. A subscription is canceled once
it expired, was revoked, or was refunded on the App Store. Until then it is active, including
trials, grace periods and auto-renew off, which sets cancelAtPeriodEnd and canceledAt instead.
The SDKs and Studio derive more detailed statuses from status and the dates:
| Situation | API and webhook status | SDK current subscription | SDK history | People label |
|---|---|---|---|---|
| Free trial | active, isTrial: true | trialing | trialing | Active |
| Paid and renewing | active | active | active | Active |
| Auto-renew off, period not over | active, cancelAtPeriodEnd: true | active or trialing | active or trialing | Cancels at period end |
| Renewal failed, in a grace period | active | Left out | expired | Grace period |
| End date passed, store hasn't reported the end | active | Left out | expired | Expired |
| Ended: expired, revoked, or refunded on the App Store | canceled | canceled | canceled | Expired (Canceled while its end date is still ahead) |
The SDK's current subscription prefers an active subscription, then trialing, then canceled.
One that isn't canceled drops out once its end date passes, even while a grace period or the
24-hour buffer below keeps its perks. A canceled one stays a candidate after its end date. With
no candidate, it is null. The SDK schema also lists none and past_due, which Voidhash never
sends. Use these statuses for account screens, such as "Renews on"; gate features on perks.
When perks end
Perks stay active until the later of expiry and the end of any grace period, plus 24 hours for late renewal notifications. A revoked or ended subscription loses its perks at once. A one-time purchase keeps them until it is refunded or revoked; a partial refund doesn't remove them.
Grace periods and billing retry
When a renewal payment fails, the store retries it. If you turned on a grace period (Billing
Grace Period in App Store Connect, or a grace period in Google Play Console), the person keeps
access while the store retries: Voidhash keeps their perks until the grace period ends, plus the 24
hours above. The subscription stays active and no webhook fires. Without a grace period, perks end
at the expiry date plus 24 hours.
If the payment recovers, the subscription renews (subscription.renewed) and the perks return.
Google Play's account hold works the same way, with access until the expiry Google reports. Payment
sources report past_due, which starts billing retry without a grace period.
Lifecycle events
| Change | Access | Webhook |
|---|---|---|
| Purchase | Perks granted | subscription.created |
| Free trial starts | Perks granted; isTrial: true | subscription.created |
| Trial converts or the subscription renews | Perks extended to the new expiry | subscription.renewed |
| Auto-renew turned off | Perks kept until expiry | subscription.cancelled |
| Auto-renew turned back on | No change | None |
| Grace period or billing retry | Perks kept, see Grace periods | None |
| Payment recovered | Perks extended | subscription.renewed |
| Expiration | Perks end | subscription.expired with expirationReason: "ended" |
| Revocation (Family Sharing removed, Google revoke) | Perks end at once | subscription.expired with expirationReason: "revoked" |
| Refund | Depends on the store, see below | purchase.refunded |
| Product change | New product's perks | Depends on the store, see below |
| One-time or consumable purchase | Perks granted | purchase.completed |
| One-time purchase refunded or revoked | Perks end | purchase.refunded or purchase.revoked |
subscription.cancelled means auto-renew is off, not that access ended; end access on
subscription.expired. A subscription Stripe ends immediately sends only subscription.expired.
See Webhook events for payloads.
Store differences
Trials
- App Store: only a free-trial introductory offer counts as a trial. Pay-up-front and pay-as-you-go offers are paid periods.
- Google Play: a free-trial phase is a trial. Without a reported phase, any offer at the start is one.
- Stripe: a period that starts before
trial_endis a trial.
Refunds
- App Store: refunding the current period ends the subscription and its perks at the refund
date and sends
subscription.expired. Refunding a past period changes nothing. - Google Play and Stripe: a refund records the money; access ends when the store expires or
revokes the subscription. A lost Stripe dispute is a refund with
refundReason: "chargeback_lost". - Payment sources: a reported refund doesn't end the subscription.
A reversed App Store refund restores the period. No webhook fires for it.
Product changes
| Store | Upgrade | Downgrade |
|---|---|---|
| App Store | Immediate; same subscription; subscription.renewed | Waits for the next renewal; subscription.renewed then |
| Google Play | A new subscription replaces the old: subscription.created for it, subscription.expired for the old | Same as upgrade when it takes effect |
| Stripe | subscription.product_changed at the switch, then subscription.renewed when it is charged | Applied when the scheduled change takes effect |
Other differences
- Resubscribing after expiry continues the same subscription on the App Store
(
subscription.renewed). Google Play and Stripe start a new one (subscription.created). - Pausing on Google Play isn't modelled: access follows the recorded expiry.
- Non-renewing subscriptions on the App Store mapped to a subscription product end one billing duration after purchase. Mapped to a non-consumable, they are a one-time purchase.
- Payment sources report
trialing,active,past_due,canceledorexpired.past_duestarts billing retry without a grace period.
Simulate the lifecycle
In the test environment, renew, expire, revoke, refund or start a 3-day grace period on a
simulated purchase with the MCP tool simulate_sandbox_event,
POST /api/v1/projects/{projectId}/sandbox/actions with a secret key, or the CLI's
voidhash sandbox renew, expire, grace-period, refund or revoke. Access changes at once,
and webhooks follow. See Simulate renewals, expiry and refunds.
Next steps
- Webhook events: every payload field.
- Check access from your backend with Node.js, PHP, Go, Rust or over HTTP.
- Test purchases (Help center): store sandboxes and simulated purchases.
- Products and perks: what each purchase grants.
Something wrong or missing on this page? Tell us by email or on Discord.