Handle refunds
React to refunds and revocations in your backend with webhooks, across stores and without double-processing.
When a store refunds or revokes a purchase, Voidhash updates the person's perks and sends a webhook. Your backend uses it to take back what lives outside Voidhash: credits a consumable bought, a license key, a seat. Perks themselves need no work from you.
Which events fire
| What happened | Webhook | Perks |
|---|---|---|
| A one-time or consumable purchase is refunded | purchase.refunded | End. |
| A subscription payment is refunded on the App Store, during its period | purchase.refunded and subscription.expired (expirationReason: "ended") | End at the refund date. |
| A subscription payment is refunded on Google Play or Stripe | purchase.refunded | Continue until the store ends the subscription. |
| A Stripe dispute is lost | purchase.refunded with refundReason: "chargeback_lost" | As for a Stripe refund. |
| Apple revokes a Family Sharing purchase, or Google Play revokes a subscription | subscription.expired (expirationReason: "revoked") or purchase.revoked | End at once. |
For a refunded subscription payment, purchaseId is null; match it on providerTransactionId
and the subscription fields. Partial refunds and reversed refunds send no webhook: a partial refund
leaves access in place, and a reversed App Store refund restores the period. The
subscription lifecycle has the store rules.
Subscribe an endpoint
In Settings → Webhooks, add an endpoint subscribed to purchase.refunded, purchase.revoked and
subscription.expired. Use a production endpoint for real refunds and a test endpoint while you
build. See Webhook delivery.
Verify, store and answer
Verify the signature on the raw body, record the event, and answer 200 within 30 seconds. Do the
work after you answer; a slow handler times out and receives the event again.
Process each event once
Delivery is at least once and not in order. Build a key from the event, skip keys you've seen, and act on the current state rather than the event alone:
import type { VoidhashWebhookEvent } from "@voidhash/node";
import { db } from "./db";
import { voidhash } from "./voidhash";
type RefundPayload = {
distinctId: string;
purchaseId?: string | null;
subscriptionId?: string;
providerTransactionId?: string | null;
occurredAt: string;
};
export async function handleRefund(event: VoidhashWebhookEvent) {
const payload = event.payload as RefundPayload;
const subject = payload.purchaseId ?? payload.providerTransactionId ?? payload.subscriptionId;
const key = `${event.type}:${subject}:${payload.occurredAt}`;
// A retried delivery must not take credits back twice.
if (!(await db.processedWebhooks.insertIfAbsent(key))) return;
switch (event.type) {
case "purchase.refunded":
case "purchase.revoked":
if (payload.purchaseId) await db.credits.revokeForPurchase(payload.purchaseId);
break;
case "subscription.expired": {
// The person may hold another subscription: ask for current access.
const hasPremium = await voidhash.entitlements.hasActivePerk({
distinctId: payload.distinctId,
perkSlug: "premium",
});
if (!hasPremium) await db.seats.release(payload.distinctId);
break;
}
default:
break;
}
}Node.js webhooks shows the verification code; the PHP, Go and Rust pages show the same in their languages.
End access after a Google Play or Stripe refund
Refunding a subscription payment on Google Play or Stripe records the money but keeps the subscription running. To end access too:
- Stripe: cancel the subscription in Stripe. Voidhash then sends
subscription.expired. - Google Play: revoke the subscription when you refund it in Play Console. Voidhash then sends
subscription.expiredwithexpirationReason: "revoked".
Refunds you start
The App Store and Google Play decide on refund requests themselves; people ask Apple or Google directly. For a payment source, Refund on the person's profile, or the API, asks your provider to refund the payment; access ends once the refund is recorded.
Test it
Make simulated purchases in a debug build, then refund a one-time purchase or revoke a subscription with the CLI. Access changes at once and the webhook follows:
voidhash sandbox refund user_123 lifetime
voidhash sandbox revoke user_123 pro_monthlyName the product when the person has several. The API and MCP server can do the same; see Simulate renewals, expiry and refunds.
When something goes wrong
- The endpoint receives nothing. A production endpoint receives only production events, and a test endpoint only non-production ones. Check its subscribed types and its delivery log.
- Signatures fail. Verify the raw body with the endpoint's current secret. See Verify webhook signatures.
- A refund was processed twice. Your key must include the subject and
occurredAt, and the insert must be atomic.
Next steps
- Webhook events: every payload field.
- Subscription lifecycle
- Check access from your backend with Node.js, PHP, Go, Rust or over HTTP
Something wrong or missing on this page? Tell us by email or on Discord.