Webhook events
Every webhook event type, when it fires, and the payload it carries.
Each webhook delivery carries one event, named by the X-Webhook-Event header and, except for
test.ping, by the payload's type field. An endpoint receives only the types it subscribes to; add a type to an existing
endpoint with Edit in Studio or voidhash webhook edit --event.
Event types
| Event | Fires when |
|---|---|
subscription.created | Voidhash sees a subscription for the first time. |
subscription.renewed | A new billing period starts and is charged, including a trial converting to paid. |
subscription.product_changed | A subscription moves to another product before the change is charged. |
subscription.cancelled | Auto-renew is turned off, or access is pulled before the period ends. |
subscription.expired | Access ends: the period or grace period ran out, or the store revoked the subscription. |
purchase.completed | A one-time or consumable purchase is recorded. |
purchase.refunded | A charge is refunded: a one-time purchase or a subscription payment. |
purchase.revoked | The store revokes a one-time purchase, ending access to it. |
person.deleted | An erasure of a person's data completes. |
test.ping | You send a test from Studio, the CLI or the API. You can't subscribe to it. |
person.created and person.updated appear in the Studio form and the API, but Voidhash doesn't
send them yet. Subscribing to them has no effect.
New event types may be added. Give your handler a default branch that answers 200 for types it
doesn't know.
Store differences
subscription.createdis not always a new purchase. When the first notification Voidhash receives for a subscription is a renewal, for example after a missed notification or a migration, you getsubscription.createdfollowed immediately bysubscription.renewedfor the samesubscriptionId. Every renewal you receive refers to a subscription you already know.- Product changes. A change charged at once, such as an App Store upgrade, arrives only as
subscription.renewednaming the new product. A change charged later, such as a Stripe price change billed with the next invoice, arrives assubscription.product_changed, and its charge later assubscription.renewed. - Revocation.
revokedcomes from App Store Family Sharing removals and Google Play revocations. A refund the store reports as a refund arrives aspurchase.refunded.
See the subscription lifecycle for how these events relate to access.
Common fields
Every subscription and purchase event carries these fields:
| Field | Type | Description |
|---|---|---|
type | string | The event type. |
projectId | string | The project the event belongs to. |
personId | string | The Voidhash person ID. |
distinctId | string | The person's distinct ID, such as the user ID your app passed to identify. |
productId | string | The Voidhash product ID. |
productSlug | string | null | The product's slug, when it has one. |
providerProductId | string | The store's product identifier. |
provider | string | apple-app-store, google-play, stripe, external (a payment source) or development (a simulated purchase). |
source | string | null | The payment source's slug when provider is external, otherwise null. |
verified | boolean | true when a store verified the purchase, false when a payment source reported it. |
environment | string | production, sandbox (a store's sandbox) or test (simulated or test mode). |
occurredAt | string | When the change happened, as an ISO 8601 UTC timestamp. |
Subscription events also carry:
| Field | Type | Description |
|---|---|---|
subscriptionId | string | The Voidhash subscription ID. |
status | string | active or canceled, after the change. |
providerSubscriptionId | string | null | The store's subscription ID. |
providerTransactionId | string | null | The store transaction behind the change. |
Money fields such as amount are { "currency", "grossAmount" }, with grossAmount in minor units
(999 is 9.99 USD). amount is null when the store reported no amount; it is never filled
with zero.
Payloads
Each example shows the fields an event adds; the "..." line stands for the common fields above.
subscription.created
isTrial is true when the subscription starts with a free trial. expiresAt is null when the
store reported no end date.
{
"type": "subscription.created",
"projectId": "proj_r3k8v2m1x9q4",
"personId": "person_k2v9x4m8q1r3",
"distinctId": "user_123",
"productId": "prod_m4q8x2v9k1r7",
"productSlug": "pro_monthly",
"providerProductId": "com.example.pro_monthly",
"provider": "apple-app-store",
"source": null,
"verified": true,
"environment": "production",
"occurredAt": "2026-10-20T09:12:44.000Z",
"subscriptionId": "sub_q9x2m7k4v1r8",
"status": "active",
"providerSubscriptionId": "2000000123456789",
"providerTransactionId": "2000000123456789",
"purchasedAt": "2026-10-20T09:12:44.000Z",
"startsAt": "2026-10-20T09:12:44.000Z",
"expiresAt": "2026-11-20T09:12:44.000Z",
"isTrial": false,
"amount": { "currency": "USD", "grossAmount": 999 }
}subscription.renewed
{
"type": "subscription.renewed",
"...": "common and subscription fields",
"renewedAt": "2026-11-20T09:12:44.000Z",
"startsAt": "2026-11-20T09:12:44.000Z",
"expiresAt": "2026-12-20T09:12:44.000Z",
"isTrial": false,
"amount": { "currency": "USD", "grossAmount": 999 }
}subscription.product_changed
The product fields name the new product; the previous fields name the one it replaced. The
period stays the same, and the event carries no amount.
{
"type": "subscription.product_changed",
"...": "common and subscription fields",
"changedAt": "2026-11-02T15:40:00.000Z",
"previousProductId": "prod_m4q8x2v9k1r7",
"previousProductSlug": "pro_monthly",
"previousProviderProductId": "price_1Q2w3E4r5T6y"
}subscription.cancelled
cancelAtPeriodEnd is true when access lasts until expiresAt, and false when access ended at
once. cancellationReason is the store's reason, or null.
{
"type": "subscription.cancelled",
"...": "common and subscription fields",
"status": "canceled",
"canceledAt": "2026-11-03T18:05:10.000Z",
"cancelAtPeriodEnd": true,
"cancellationReason": null,
"expiresAt": "2026-11-20T09:12:44.000Z"
}subscription.expired
expirationReason is ended when the period or grace period ran out or the store ended the
subscription, for example after a refund. It is revoked when the store took access away before
the period ended.
{
"type": "subscription.expired",
"...": "common and subscription fields",
"expiredAt": "2026-11-20T09:12:44.000Z",
"expirationReason": "ended"
}purchase.completed
purchaseKind is one_time or consumable. providerKey identifies the purchase at the store.
{
"type": "purchase.completed",
"...": "common fields",
"purchaseId": "pur_v8q2m4k9x1r7",
"purchaseKind": "one_time",
"providerKey": "2000000123456789",
"providerTransactionId": "2000000123456789",
"purchasedAt": "2026-11-05T09:12:44.000Z",
"amount": { "currency": "USD", "grossAmount": 4999 }
}purchase.refunded
purchaseId is null when the refund matches no stored one-time purchase, as for a refunded
subscription payment. amount is the original charge.
{
"type": "purchase.refunded",
"...": "common fields",
"purchaseId": "pur_v8q2m4k9x1r7",
"providerTransactionId": "2000000123456789",
"refundedAt": "2026-11-06T11:00:00.000Z",
"refundReason": null,
"amount": { "currency": "USD", "grossAmount": 4999 }
}purchase.revoked
{
"type": "purchase.revoked",
"...": "common fields",
"purchaseId": "pur_v8q2m4k9x1r7",
"providerKey": "2000000123456789",
"providerTransactionId": "2000000123456789",
"revokedAt": "2026-11-07T08:30:00.000Z",
"revocationReason": null,
"amount": { "currency": "USD", "grossAmount": 4999 }
}person.deleted
This event has its own payload and no common fields. It never includes the distinct ID, email or
name, so match it on personId and delete your own copy of that person's data.
{
"type": "person.deleted",
"projectId": "proj_r3k8v2m1x9q4",
"personId": "person_k2v9x4m8q1r3",
"erasureId": "person_erasure_t7k2v9m4x1q8",
"erasedAt": "2026-11-08T12:00:00.000Z"
}test.ping
A test payload has no type field. Read the event type from the X-Webhook-Event header.
{
"message": "This is a test webhook delivery",
"timestamp": "2026-11-05T09:12:44.000Z"
}Next steps
- Webhook delivery: endpoints, signatures, retries and the delivery log.
- Receive webhooks with Node.js, PHP, Go, Rust or another language.
- Subscription lifecycle
Something wrong or missing on this page? Tell us by email or on Discord.