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 script creates an endpoint that subscribes to three events and prints its signing secret:

scripts/create-webhook.php
$endpoint = $voidhash->webhooks()->createWebhookEndpoint(
    name: "production-backend",
    url: "https://api.example.com/webhooks/voidhash",
    events: ["subscription.created", "subscription.renewed", "purchase.completed"],
);

echo $endpoint["secret"]; // whsec_...

The URL must use http: or https:, and you must subscribe to at least one event. 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 testWebhookEndpoint.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.

person.* events are reserved

You can select person.created, person.updated, and person.deleted 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, 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:

FieldTypeNotes
typeThe event nameTells the payload types apart.
projectIdstringThe Voidhash project the event belongs to.
personIdstringThe Voidhash person id.
distinctIdstringThe identifier your app passed to identify().
productIdstringThe Voidhash product id.
productSlugstring | nullThe slug configured in Studio, when the product has one.
providerProductIdstringThe store's product identifier.
providerapple-app-store | google-play | stripe | developmentThe provider that drove the transition.
environmentproduction | sandbox | developmentWhether the purchase was live, made in the store sandbox, or simulated in development.
occurredAtISO-8601 UTC stringWhen the transition happened.

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.

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:

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-EventThe event name, for example purchase.completed.
X-Webhook-TimestampThe Unix time in seconds when the request was signed.
X-Webhook-Signaturev1= 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 you must verify the raw request body before anything decodes it into an array. Read it with file_get_contents("php://input") or your framework's raw-body accessor, never from $_POST.

This handler reads the raw body, verifies the signature, and acknowledges the delivery before doing any work:

src/WebhookController.php
use Voidhash\Webhook;
use Voidhash\SignatureVerificationException;

$app->post("/webhooks/voidhash", function (Request $request, Response $response) {
    $payload = (string) file_get_contents("php://input");

    try {
        $event = Webhook::constructEvent(
            payload: $payload,
            signatureHeader: $request->getHeaderLine("X-Webhook-Signature"),
            timestampHeader: $request->getHeaderLine("X-Webhook-Timestamp"),
            secret: getenv("VOIDHASH_WEBHOOK_SECRET"),
        );
    } catch (SignatureVerificationException) {
        // "missing_header" | "invalid_signature"
        // | "timestamp_out_of_tolerance" | "invalid_payload"
        return $response->withStatus(400);
    }

    // Acknowledge fast, then do the work out of band.
    handleEventAsync($event);

    return $response->withStatus(200);
});

constructEvent returns an associative array with type, payload, and timestamp keys. 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.

Respond 4xx when verification fails

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. The helper rejects a timestamp more than 300 seconds away from the current time, in either direction. If your infrastructure adds delay, raise the limit with toleranceSeconds. Pass now (a Unix timestamp) to make tests deterministic.

This call allows a 600 second window:

$event = Webhook::constructEvent(
    payload: $payload,
    signatureHeader: $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"],
    timestampHeader: $_SERVER["HTTP_X_WEBHOOK_TIMESTAMP"],
    secret: getenv("VOIDHASH_WEBHOOK_SECRET"),
    toleranceSeconds: 600,
);

Keep your server clocks synchronized. Clock drift is the most common cause of timestamp_out_of_tolerance 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.
  • After the fifth failed attempt the delivery is marked exhausted and 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. Under PHP-FPM that usually means enqueueing a job. Under Swoole or RoadRunner you can spawn a coroutine instead. 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 listWebhookDeliveries, getWebhookDelivery, and retryWebhookDelivery on the webhooks resource.

Test an endpoint

To send a test delivery, choose Send Test from an endpoint's actions menu in Studio, or call the API:

$voidhash->webhooks()->testWebhookEndpoint(endpointId: "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 = $voidhash->webhooks()->rotateWebhookSecret(endpointId: "wh_ep_...");

echo $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.

Next steps