Receive webhooks
Get subscription and purchase changes pushed to your backend as they happen.
Webhooks tell your server when a subscription or purchase changes, without waiting for the customer to open the app. Renewals, cancellations, expiries, and refunds all originate at the store. A customer who never launches the app again still produces events your backend needs to know about.
Use webhooks to keep your own database in sync. For an access decision, keep server-side entitlement checks as the authority. Webhooks are a push channel, not a lock: they tell you that something changed, but they do not decide access.
Create an endpoint
In Studio, open Settings → Webhooks, add an endpoint URL, and select the events to subscribe to.
Copy the signing secret and store it next to your secret key. The secret looks like whsec_
followed by 64 hex characters.
You can also create the same endpoint from your backend. This program creates an endpoint that
subscribes to three events and prints its signing secret. The type of the event names comes from
the module's generated api package:
endpoint, err := client.Webhooks.Endpoints.Create(ctx, voidhash.CreateWebhookEndpointParams{
Name: "production-backend",
Url: "https://api.example.com/webhooks/voidhash",
Events: []api.CreateWebhookBodyJsonEncodingEvents{
"subscription.created",
"subscription.renewed",
"purchase.completed",
},
})
fmt.Println(endpoint.Secret) // whsec_...The URL must use http: or https: and point to a public address: Voidhash rejects localhost
and private or internal network addresses, and does not deliver to a host that resolves to one. You
must subscribe to at least one event. Unknown event names are rejected.
Event catalog
| Event | Fires when | Payload highlights |
|---|---|---|
subscription.created | A subscription becomes known to Voidhash for a person. | subscriptionId, startsAt, expiresAt, purchasedAt, isTrial, amount |
subscription.renewed | A renewal advances the subscription's period. | subscriptionId, renewedAt, startsAt, expiresAt, isTrial, amount |
subscription.product_changed | A subscription moves to another product before it is charged for. | subscriptionId, changedAt, previousProductId, previousProviderProductId |
subscription.cancelled | Auto-renew is turned off. | canceledAt, cancelAtPeriodEnd, cancellationReason, expiresAt |
subscription.expired | A subscription's access ends: its period ran out, or the store revoked it. | expiredAt, expirationReason |
purchase.completed | A non-subscription purchase is recorded. | purchaseId, purchaseKind, purchasedAt, providerKey, amount |
purchase.refunded | A purchase or transaction is refunded. | purchaseId, refundedAt, refundReason, amount |
purchase.revoked | The store revokes a non-subscription purchase, ending access to it. | purchaseId, revokedAt, revocationReason, amount |
person.deleted | A request to delete a person's data has completed. | personId, erasureId, erasedAt |
test.ping | You send a test from Studio or call Webhooks.Endpoints.Test. | message, timestamp |
subscription.created does not always mean a fresh purchase. Voidhash emits it the first time it
sees a subscription. That is usually the subscription's start, but it can also be a renewal, for
example when the start notification never reached Voidhash, or when an app migrated onto Voidhash
in the middle of a subscription. In that case Voidhash delivers subscription.created first and
subscription.renewed immediately after, both for the same subscriptionId. As a result, every
renewal you receive refers to a subscription you have already been told about.
subscription.product_changed is sent when a subscription moves to another product before
Voidhash records a charge for the change, for example a Stripe price change billed with the next
invoice. Access moves to the new product at once. The product fields name the new product, and
previousProductId, previousProductSlug and previousProviderProductId name the product it
replaced. changedAt is when the change took effect. The period stays the same and the event
carries no amount: the charge for the change arrives later as a subscription.renewed of its own. A
change that arrives together with its charge, such as an App Store upgrade, is delivered only as
subscription.renewed, which already names the new product.
An endpoint receives only the events it subscribes to, so an existing endpoint receives
subscription.product_changed once you add it to the endpoint's events.
person.deleted has its own payload: type, projectId, personId, erasureId and erasedAt.
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.created and person.updated can be selected on an endpoint
but nothing emits them yet.
Payload shape
The HTTP body is the bare JSON payload, with no envelope around it. The event name appears twice:
in the X-Webhook-Event header and in the payload's type field.
Every lifecycle payload carries these fields:
| Field | Type | Notes |
|---|---|---|
type | The event name | Tells the payload types apart. |
projectId | string | The Voidhash project the event belongs to. |
personId | string | The Voidhash person id. |
distinctId | string | The identifier your app passed to identify(). |
productId | string | The Voidhash product id. |
productSlug | string | null | The slug configured in Studio, when the product has one. |
providerProductId | string | The store's product identifier. |
provider | apple-app-store | google-play | stripe | development | external | The provider that drove the transition. external is a payment source that reports its own purchases. |
environment | production | sandbox | development | Whether the purchase was live, made in the store sandbox, or simulated in development. |
occurredAt | ISO-8601 UTC string | When the transition happened. |
source | string | null | The payment source's slug for external; null for the stores. |
verified | boolean | true when the store verified the purchase, false when a payment source reported it. |
Subscription events add four more fields: subscriptionId, status (active or canceled),
providerSubscriptionId, and providerTransactionId. The two provider ids may be null.
Purchase events add purchaseId and providerTransactionId. On purchase.refunded, purchaseId
is null when the refund could not be matched to a stored purchase.
subscription.expired carries an expirationReason: ended when the subscription's period or
grace period ran out or the store ended it, for example after a refund, and revoked when the store
took access away before the period ended, for example when a Family Sharing member loses access.
Either way the person no longer has access. A revoked one-time purchase is reported as
purchase.revoked.
Monetary fields such as amount have the shape { currency, grossAmount }, with grossAmount in
minor units (cents rather than dollars, for example). When the provider event reported no amount,
the field is null. null means the amount was not reported. Voidhash deliberately does not
replace it with zero.
Here is a complete subscription.renewed payload:
{
"type": "subscription.renewed",
"projectId": "proj_...",
"personId": "person_...",
"distinctId": "user_123",
"productId": "prod_...",
"productSlug": "monthly",
"providerProductId": "com.example.monthly",
"provider": "apple-app-store",
"source": null,
"verified": true,
"environment": "production",
"occurredAt": "2026-08-20T09:12:44.000Z",
"subscriptionId": "sub_...",
"status": "active",
"providerSubscriptionId": "1000000123456789",
"providerTransactionId": "1000000987654321",
"startsAt": "2026-08-20T09:12:44.000Z",
"expiresAt": "2026-09-20T09:12:44.000Z",
"renewedAt": "2026-08-20T09:12:44.000Z",
"isTrial": false,
"amount": { "currency": "USD", "grossAmount": 999 }
}Verify the signature
Voidhash signs every request and sends three headers:
| Header | Value |
|---|---|
X-Webhook-Event | The event name, for example purchase.completed. |
X-Webhook-Timestamp | The Unix time in seconds when the request was signed. |
X-Webhook-Signature | v1= followed by the hex-encoded HMAC-SHA256 of ${timestamp}.${rawBody}. |
The HMAC key is the raw UTF-8 endpoint secret. The signature covers the exact bytes Voidhash sent,
so read the body with io.ReadAll(r.Body) and verify it before anything decodes it. A framework
that re-serializes the body breaks verification.
This net/http handler reads the raw body, verifies the signature, and acknowledges the delivery
before doing any work:
import "github.com/voidhashcom/voidhash-go"
func voidhashWebhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := voidhash.ConstructWebhookEvent(rawBody, r.Header, os.Getenv("VOIDHASH_WEBHOOK_SECRET"))
if err != nil {
var verificationErr *voidhash.WebhookVerificationError
if errors.As(err, &verificationErr) {
log.Printf("rejected webhook: %s: %s", verificationErr.Reason, verificationErr.Detail)
}
w.WriteHeader(http.StatusBadRequest)
return
}
// Acknowledge fast, then do the work out of band.
go handleEvent(event)
w.WriteHeader(http.StatusOK)
}When verification fails, ConstructWebhookEvent returns a *voidhash.WebhookVerificationError.
Its Reason field tells the failures apart, and Detail describes the failure for your logs:
Reason | Cause |
|---|---|
missing_header | One of the three headers is absent, empty, or sent more than once. |
timestamp_out_of_tolerance | The timestamp is malformed or more than 300 seconds from your server's clock. |
invalid_signature | The signature does not match the body. Check the endpoint secret. |
invalid_payload | The signature is valid, but the body is not valid JSON. |
On success you get a *voidhash.WebhookEvent with three fields:
| Field | Type | Description |
|---|---|---|
Type | string | The event name from the X-Webhook-Event header. |
Payload | json.RawMessage | The verified JSON body. |
Timestamp | time.Time | When Voidhash signed the request. |
The library defines a constant for each event name, such as
voidhash.WebhookEventSubscriptionRenewed and voidhash.WebhookTestPing. Event names added after
your library release pass through as plain strings in Type, so always give your switch on the type
a default case. When you need a typed payload, decode it on demand:
switch event.Type {
case voidhash.WebhookEventSubscriptionRenewed:
var renewed struct {
SubscriptionID string `json:"subscriptionId"`
ExpiresAt string `json:"expiresAt"`
}
if err := json.Unmarshal(event.Payload, &renewed); err != nil {
return err
}
// Update your own record of the subscription.
case voidhash.WebhookTestPing:
// Nothing to do.
default:
// An event this handler does not know yet. Ignore it.
}Respond with a 4xx status when verification fails. Voidhash cannot make a rejected request valid
by signing it again, and retrying a forged request costs you five more deliveries.
Replay protection
Replay protection stops an attacker from re-sending a captured request later.
ConstructWebhookEvent rejects a timestamp more than 300 seconds away from the current time, in
either direction, and the window cannot be changed.
If your infrastructure adds delay, check the signature with VerifyWebhookSignature instead. It
takes the tolerance and the current time, and returns whether the request is valid. Pass a fixed
time in tests to make them deterministic. This call allows a 600 second window:
valid := voidhash.VerifyWebhookSignature(
rawBody,
r.Header.Get("X-Webhook-Signature"),
r.Header.Get("X-Webhook-Timestamp"),
secret,
600*time.Second,
time.Now(),
)VerifyWebhookSignature checks only the signature and the timestamp. Read the event name from the
X-Webhook-Event header and decode the body yourself.
Keep your server clocks synchronized. Clock drift is the most common cause of a
timestamp_out_of_tolerance failure on an integration that is otherwise correct.
Delivery semantics
Voidhash emits a lifecycle event only when a state transition actually happened, and only after the database transaction that produced it has committed. If a store sends the same notification twice, the purchase ledger recognizes the duplicate by its idempotency key (a stable identifier for the notification) and drops it before any event is built. One real transition therefore produces one event per subscribed endpoint.
Delivery itself is at-least-once. Voidhash delivers a webhook again when your handler is slow, or when it succeeds but fails to respond in time. The retry schedule is:
- A delivery is attempted up to 5 times.
- After a failed attempt, the next attempt is scheduled 5 minutes later, then 30 minutes, then 2 hours, then 24 hours.
- Any response outside
2xx, or a response slower than 30 seconds, counts as a failed attempt. Redirects are not followed, so a3xxresponse fails too. - After the fifth failed attempt the delivery is marked
exhaustedand is not retried again.
Because of this, your handler must do two things.
First, make it idempotent, so that processing the same event twice has no extra effect. Every
payload carries stable identifiers. Build a key from type, subscriptionId or purchaseId, and
occurredAt. Record each key you have processed, and ignore any event whose key you have already
seen.
Second, acknowledge quickly. Return 200 as soon as the signature verifies, then process the event
asynchronously. Hand the event to a worker pool or enqueue it. A handler that does real work before
responding is the usual reason a delivery times out and arrives a second time.
You can see each delivery and its per-attempt history in Studio. The same data is readable through
List, Get and Retry on Webhooks.Deliveries.
Test an endpoint
To send a test delivery, choose Send test from an endpoint's actions menu in Studio, or call the API:
client.Webhooks.Endpoints.Test(ctx, "wh_ep_...")Either way, Voidhash sends a test.ping delivery with the body
{ "message": "This is a test webhook delivery", "timestamp": "..." }. It is signed exactly like a
real event, so it is the fastest way to confirm that your signature verification, routing, and
response time are correct before any money is involved.
Rotate a secret
If a secret is exposed, rotate it immediately. This call generates a new secret for the endpoint and returns it:
endpoint, err := client.Webhooks.Endpoints.RotateSecret(ctx, "wh_ep_...")
fmt.Println(endpoint.Secret) // new whsec_...Rotation applies to deliveries created after it. A delivery that was already queued keeps the secret it was signed with, so its retries still arrive under the old one. Have your handler accept both secrets during the rotation window, then drop the old secret once pending retries have drained.