VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Essentials
API overview
Authentication
Event capture
Push device API
Webhooks
Webhook delivery
Webhook events
Projects & keys
Projects
API keys
Audit log
Customers & access
Persons
Products
Perks
Sandbox
Paywalls
Paywalls
Paywall locations
Components
Component registry
Assets
Experiments & flags
Experiments
Feature flags
Analytics & events
Analytics
Analytics alerts
Events
Ingest policy
VoidQL
Tracking domains
Ads
Ads
Ad accounts
Meta measurement
Payments & stores
Payment providers
Payment provider products
Purchase history imports
Payment sources
Webhooks & notifications
Webhooks
Push notifications
Push configurations
Automations
Organization & team
Organizations
Members
Invitations
Billing
Your account
Profile
Session
Notices
Connected apps
Terms of Service
Developer tools
Schema
SDK

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

EventFires when
subscription.createdVoidhash sees a subscription for the first time.
subscription.renewedA new billing period starts and is charged, including a trial converting to paid.
subscription.product_changedA subscription moves to another product before the change is charged.
subscription.cancelledAuto-renew is turned off, or access is pulled before the period ends.
subscription.expiredAccess ends: the period or grace period ran out, or the store revoked the subscription.
purchase.completedA one-time or consumable purchase is recorded.
purchase.refundedA charge is refunded: a one-time purchase or a subscription payment.
purchase.revokedThe store revokes a one-time purchase, ending access to it.
person.deletedAn erasure of a person's data completes.
test.pingYou 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.created is 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 get subscription.created followed immediately by subscription.renewed for the same subscriptionId. 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.renewed naming the new product. A change charged later, such as a Stripe price change billed with the next invoice, arrives as subscription.product_changed, and its charge later as subscription.renewed.
  • Revocation. revoked comes from App Store Family Sharing removals and Google Play revocations. A refund the store reports as a refund arrives as purchase.refunded.

See the subscription lifecycle for how these events relate to access.

Common fields

Every subscription and purchase event carries these fields:

FieldTypeDescription
typestringThe event type.
projectIdstringThe project the event belongs to.
personIdstringThe Voidhash person ID.
distinctIdstringThe person's distinct ID, such as the user ID your app passed to identify.
productIdstringThe Voidhash product ID.
productSlugstring | nullThe product's slug, when it has one.
providerProductIdstringThe store's product identifier.
providerstringapple-app-store, google-play, stripe, external (a payment source) or development (a simulated purchase).
sourcestring | nullThe payment source's slug when provider is external, otherwise null.
verifiedbooleantrue when a store verified the purchase, false when a payment source reported it.
environmentstringproduction, sandbox (a store's sandbox) or test (simulated or test mode).
occurredAtstringWhen the change happened, as an ISO 8601 UTC timestamp.

Subscription events also carry:

FieldTypeDescription
subscriptionIdstringThe Voidhash subscription ID.
statusstringactive or canceled, after the change.
providerSubscriptionIdstring | nullThe store's subscription ID.
providerTransactionIdstring | nullThe 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.

subscription.created
{
  "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

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.

subscription.product_changed
{
  "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.

subscription.cancelled
{
  "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.

subscription.expired
{
  "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.

purchase.completed
{
  "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.

purchase.refunded
{
  "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

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.

person.deleted
{
  "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.

test.ping
{
  "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.

PreviousWebhook deliveryNextList projects

On this page

Event typesStore differencesCommon fieldsPayloadssubscription.createdsubscription.renewedsubscription.product_changedsubscription.cancelledsubscription.expiredpurchase.completedpurchase.refundedpurchase.revokedperson.deletedtest.pingNext steps