Migrate in observer mode

Run Voidhash alongside an existing billing SDK and move purchase ownership when you are ready.

Observer mode lets your existing billing SDK keep control of purchases while Voidhash observes store transactions and builds customer and entitlement state. It is the supported way to migrate an app that already ships in-app purchases.

The one invariant

Both SDKs may stay initialized in the same build. What must never be true is two SDKs completing the same store transaction.

Exactly one integration may finish (StoreKit) or acknowledge (Google Play) a transaction. The other one may read it, report it, and render UI from it. When Voidhash runs in observer mode, it never finishes or acknowledges anything, so your existing SDK stays the sole owner.

Everything else on this page follows from keeping that invariant true at every moment, including during the release where ownership changes.

Enable observer mode

src/lib/voidhash.ts
import { createVoidhashClient } from "@voidhash/react-native";

export const voidhash = createVoidhashClient("vh_pk_...", {
  readOnly: true,
});
var options = VoidhashOptions()
options.readOnly = true

let voidhash = Voidhash.configure(publishableKey: "vh_pk_...", options: options)
val voidhash = Voidhash.configure(
    context = this,
    publishableKey = "vh_pk_...",
    options = VoidhashOptions(readOnly = true),
)
BehaviorObserver modeOwner mode (default)
Starts purchasesNoYes
Observes store transactionsYesYes
Syncs transactions to VoidhashYesYes
Finishes / acknowledges store transactionsNoYes, after a successful sync
Reads products, persons, entitlementsYesYes

Voidhash also sends x-observer-mode: true on SDK requests while the mode is active, so the server records that this client is observing rather than owning purchases.

APIs blocked in observer mode

Starting a purchase is blocked, as are write flows that depend on owning the transaction — hosted paywall purchase actions fail the same way and report an error through their callback or delegate.

Reading products, persons, entitlements, feature flags, and paywall assignments remains available, as do restores, queued analytics, and person attribute updates.

Switch ownership at runtime

Observer mode is not fixed at construction. Flip it on the live client:

voidhash.client.setReadOnly(false); // Voidhash now owns purchases
voidhash.client.setReadOnly(true); // back to observing

if (voidhash.client.isReadOnly) {
  // render the existing SDK's purchase UI
}
await voidhash.setReadOnly(false) // Voidhash now owns purchases
await voidhash.setReadOnly(true)  // back to observing
voidhash.setReadOnly(false) // Voidhash now owns purchases
voidhash.setReadOnly(true)  // back to observing

The switch takes effect at the next decision point of each consumer: purchase gating, the transaction observer's finish/acknowledge decision, and the x-observer-mode header on later requests.

In-flight purchases keep the mode they started with

A purchase that has already begun completes under the mode that was active when it started, so a mid-purchase flip can never strand that transaction unfinished with the store. Transactions already being processed when the call lands may also complete under the previous mode.

Switching at runtime avoids recreating the client, which would drop the native store connection, the caches, and the analytics queue.

Roll out behind a feature flag

Use one flag to decide both halves of ownership at once — which SDK's UI starts a purchase, and which SDK finishes the resulting transaction. Reading them from a single value is what keeps the invariant true:

app/paywall-entry.tsx
const voidhashOwnsPurchases = useMyFeatureFlag("voidhash_purchases");

useEffect(() => {
  // One source of truth: the same flag that picks the UI picks the owner.
  voidhash.client.setReadOnly(!voidhashOwnsPurchases);
}, [voidhashOwnsPurchases]);

return voidhashOwnsPurchases ? <VoidhashUpgradeScreen /> : <LegacyUpgradeScreen />;

Apply the mode before presenting purchase UI, not after a customer taps buy. Roll the flag out to a small percentage first, compare both platforms in the sandbox, then widen it.

To ship the SDK completely inert — no store connection, no network, no listeners — construct it with enabled: false. The flag is fixed at construction; enabling later means creating a new client, which is cheap because a disabled one never built its runtime. On React Native, mount <voidhash.Provider> unconditionally either way so hook order never changes between builds.

Fall back when Voidhash cannot present

Every SDK reports why a paywall was not presented. Branch on that outcome to decide whether to fall back to the existing SDK's paywall or an app-owned screen:

const result = await paywall.show();

switch (result.status) {
  case "shown":
    break;
  case "not_assigned":
  case "native_unavailable":
  case "disabled":
  case "not_initialized":
    showLegacyPaywall();
    break;
  case "failed":
  case "initialization_failed":
    reportError("show", result.error);
    showLegacyPaywall();
    break;
}

Purchase and restore failures inside an already-visible paywall arrive on the hook's callbacks (onError, onPreloadError) instead of through show().

let result = try await voidhash.presentPaywall(location: "settings-upsell")

switch result {
case .shown:
    break
case .notAssigned:
    showLegacyPaywall()
case .failed:
    showLegacyPaywall()
}
val shown = voidhash.presentPaywall(activity, location = "settings-upsell")

if (!shown) {
    showLegacyPaywall()
}

Keep two categories apart:

  • Fallback-safe — nothing was presented and no money moved, so showing the other paywall is always correct: not assigned, not initialized, presenter unavailable, disabled, or a resolve or presentation failure.
  • Purchase and restore failures — delivered through the SDK's error callbacks while the paywall is already on screen and a store flow may have started. Do not respond by opening a second paywall; surface the error and let the customer retry.

Migration sequence

Ship Voidhash in observer mode

Add the SDK with observer mode on and leave the existing integration untouched. Nothing about the purchase flow changes in this release.

Verify in the sandbox

On both iOS and Android, run a new purchase, a renewal, a cancellation, an expiry, a refund, and a restore. Confirm each one appears on the person in Studio and produces the perk grants you expect.

Compare against your current source of truth

For a sample of real customers, compare Voidhash entitlement grants with the access your existing system grants. Investigate every mismatch before continuing — this step is what makes the switch safe.

Move purchase UI and ownership together

Put both behind one flag, as above. When the flag is on, Voidhash presents the paywall and takes over purchase ownership. When it is off, both revert. Never split them across two releases.

Remove the previous SDK

Once the flag is at 100% and store transactions reconcile cleanly, delete the old integration and drop the observer-mode option from the client configuration.

Retest new purchases, renewals, cancellations, and restores on both platforms after each step. A transaction left unacknowledged on Google Play is refunded automatically after three days, so ownership gaps show up as revenue loss rather than as errors.

Next steps