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, so a customer who never launches the app again still produces events your backend needs.

Use webhooks to keep your own database in sync. Keep server-side entitlement checks as the authority for an access decision: webhooks are a push channel, not a lock.

Create an endpoint

In Studio, open Settings → Webhooks, add an endpoint URL, and select the events to subscribe to. Copy the signing secret — it looks like whsec_ followed by 64 hex characters — and store it next to your secret key.

The same endpoint can be created from a backend:

src/bin/create-webhook.rs
let endpoint = voidhash
    .webhooks
    .create_webhook_endpoint(&voidhash::CreateWebhookEndpoint {
        name: "production-backend".into(),
        url: "https://api.example.com/webhooks/voidhash".into(),
        events: vec![
            "subscription.created".into(),
            "subscription.renewed".into(),
            "purchase.completed".into(),
        ],
    })
    .await?;

println!("{}", endpoint.secret); // whsec_...

The URL must be http: or https: and at least one event is required. Unknown event names are rejected.

Event catalog

EventFires whenPayload highlights
subscription.createdA subscription becomes known to Voidhash for a person.subscriptionId, startsAt, expiresAt, purchasedAt, isTrial, amount
subscription.renewedA renewal advances the subscription's period.subscriptionId, renewedAt, startsAt, expiresAt, isTrial, amount
subscription.cancelledAuto-renew is turned off, or access is revoked immediately.canceledAt, cancelAtPeriodEnd, cancellationReason, expiresAt
subscription.expiredA subscription's access period ends.expiredAt
purchase.completedA non-subscription purchase is recorded.purchaseId, purchaseKind, purchasedAt, providerKey, amount
purchase.refundedA purchase or transaction is refunded.purchaseId, refundedAt, refundReason, amount
test.pingYou send a test from Studio or call test_webhook_endpoint.message, timestamp

subscription.created is not always a fresh purchase

A subscription becomes known to Voidhash the first time we see it, which is usually its start but can also be a renewal — a start notification we never received, or an app that migrated onto Voidhash mid-subscription. In that case subscription.created is delivered first, immediately followed by subscription.renewed for the same subscriptionId, so every renewal you receive refers to a subscription you have already been told about.

person.* events are reserved

person.created, person.updated, and person.deleted can be selected on an endpoint, but nothing emits them yet. Do not build on them until they are announced as delivered.

Payload shape

The HTTP body is the bare JSON payload — there is no envelope. The event name is repeated in the X-Webhook-Event header and in the payload's type field.

Every lifecycle payload carries these fields:

FieldTypeNotes
typeThe event nameDiscriminator for the payload union.
projectIdstringVoidhash project the event belongs to.
personIdstringVoidhash person id.
distinctIdstringThe identifier your app passed to identify().
productIdstringVoidhash product id.
productSlugstring | nullThe slug configured in Studio, when the product has one.
providerProductIdstringThe store's product identifier.
providerapple-app-store | google-play | stripe | developmentWhich provider drove the transition.
environmentproduction | sandbox | developmentLive, store sandbox, or simulated development purchase.
occurredAtISO-8601 UTC stringWhen the transition happened.

Subscription events add 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 anchored to a stored purchase.

Monetary fields are { currency, grossAmount } with grossAmount in minor units, or null when the provider event reported no amount. null means "not reported" and is deliberately not zero-filled.

subscription.renewed
{
  "type": "subscription.renewed",
  "projectId": "proj_...",
  "personId": "person_...",
  "distinctId": "user_123",
  "productId": "prod_...",
  "productSlug": "monthly",
  "providerProductId": "com.example.monthly",
  "provider": "apple-app-store",
  "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:

HeaderValue
X-Webhook-EventEvent name, for example purchase.completed.
X-Webhook-TimestampUnix seconds at signing time.
X-Webhook-Signaturev1= followed by the hex HMAC-SHA256 of ${timestamp}.${rawBody}.

The HMAC key is the raw UTF-8 endpoint secret. The signature covers the exact bytes that were sent, so verify against the raw body bytes before any deserialization.

src/routes/webhooks.rs
use axum::{http::HeaderMap, response::IntoResponse};
use voidhash::webhook;

async fn voidhash_webhook(
    raw: axum::body::Bytes,
    headers: HeaderMap,
) -> Result<impl IntoResponse, StatusCode> {
    let secret = std::env::var("VOIDHASH_WEBHOOK_SECRET").unwrap();

    let event = webhook::construct_event(
        &raw,
        headers.get("X-Webhook-Signature")?.to_str()?,
        headers.get("X-Webhook-Timestamp")?.to_str()?,
        &secret,
    )
    .map_err(|_| StatusCode::BAD_REQUEST)?;
    // WebhookError::MissingHeader | InvalidSignature
    // | TimestampOutOfRange | InvalidPayload

    // Acknowledge fast, then do the work out of band.
    tokio::spawn(handle_event(event));

    Ok(StatusCode::OK)
}

construct_event returns a WebhookEvent with event_type: String, payload: serde_json::Value, and timestamp: i64. Event names added after your library release pass through as plain strings in event_type, so always give your match on the type a default arm. Typed payloads can be decoded on demand with serde_json::from_value::<SubscriptionRenewed>(event.payload).

Reject, do not retry, a bad signature

Respond 4xx when verification fails. Voidhash never signs its way out of a rejected request, and retrying a forged one costs you five more deliveries.

Replay protection

The helper rejects a timestamp more than 300 seconds away from the current time, in either direction. Pass tolerance_seconds if your infrastructure adds delay, and pass now to make tests deterministic:

let event = webhook::construct_event_with_options(
    &raw,
    signature_header,
    timestamp_header,
    &secret,
    webhook::VerifyOptions {
        tolerance_seconds: 600,
        now: Some(1_787_000_000),
    },
)?;

Keep server clocks synchronized. Clock drift is the most common cause of TimestampOutOfRange on an otherwise correct integration.

Delivery semantics

Lifecycle events are emitted only when a state transition actually happened, after the database transaction that produced it has committed. A redelivered store notification collapses on the purchase ledger's idempotency key before any event is built, so one real transition produces one event per subscribed endpoint.

Delivery itself is at-least-once. A handler that is slow, or that succeeds but fails to respond in time, is delivered again:

  • A delivery is attempted up to 5 times.
  • After a failed attempt the next one is scheduled 5 minutes, then 30 minutes, then 2 hours, then 24 hours later.
  • Any response outside 2xx, or a response slower than 30 seconds, counts as a failed attempt.
  • After the fifth failed attempt the delivery is marked exhausted and is not retried again.

Make your handler idempotent. The payloads carry stable identifiers — key on type plus subscriptionId or purchaseId plus occurredAt, record what you have processed, and ignore repeats.

Return 200 as soon as the signature verifies, then process asynchronously — spawn onto the tokio runtime or push into a queue. A handler that does real work inline is the usual reason a delivery times out and is duplicated.

Deliveries and their per-attempt history are visible in Studio, and readable through list_webhook_deliveries, get_webhook_delivery, and retry_webhook_delivery on the webhooks resource.

Test an endpoint

Choose Send Test from an endpoint's actions menu in Studio, or call the API:

voidhash
    .webhooks
    .test_webhook_endpoint("wh_ep_...")
    .await?;

This 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

Rotate immediately if a secret is exposed:

let endpoint = voidhash
    .webhooks
    .rotate_webhook_secret("wh_ep_...")
    .await?;

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 the handler accept both secrets across the rotation window, then drop the old secret once pending retries have drained.

Next steps