VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Guides
Launch
Go-live checklist
Make your first App Store purchase
Make your first Google Play purchase
Troubleshoot
Troubleshooting
Recover from incidents
SDK error codes
Purchase error codes
API error codes
FAQ
Recipes
Win back subscribers
Handle plan changes
Unlock the app after a web purchase
Link accounts across platforms
Handle refunds
Register devices for push
Migrate
Migrate to Voidhash
Migrate from RevenueCat
Migrate from Adapty
Migrate from Superwall
Run in observer mode
Privacy and hosting
Delete personal data
Self-hosting
Operate a self-hosted server

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 happenedWebhookPerks
A one-time or consumable purchase is refundedpurchase.refundedEnd.
A subscription payment is refunded on the App Store, during its periodpurchase.refunded and subscription.expired (expirationReason: "ended")End at the refund date.
A subscription payment is refunded on Google Play or Stripepurchase.refundedContinue until the store ends the subscription.
A Stripe dispute is lostpurchase.refunded with refundReason: "chargeback_lost"As for a Stripe refund.
Apple revokes a Family Sharing purchase, or Google Play revokes a subscriptionsubscription.expired (expirationReason: "revoked") or purchase.revokedEnd 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:

server/handle-refund.ts
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.expired with expirationReason: "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_monthly

Name 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.

PreviousLink accounts across platformsNextRegister devices for push

On this page

Which events fireSubscribe an endpointVerify, store and answerProcess each event onceEnd access after a Google Play or Stripe refundRefunds you startTest itWhen something goes wrongNext steps