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 watches store transactions and builds customer and entitlement state from them. It is the supported way to migrate an app that already ships in-app purchases. This page explains the one rule that keeps the migration safe, how to turn observer mode on, and how to hand ownership to Voidhash step by step.

The one invariant

Both SDKs can stay initialized in the same build. The one thing that must never happen is two SDKs completing the same store transaction.

Exactly one integration may finish a transaction (StoreKit) or acknowledge it (Google Play). 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 hands.

Enable observer mode

Observer mode is a client option. Set readOnly to true when you create the client:

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),
)

With the option on, the SDK still observes and syncs transactions, but it never starts or completes a purchase. This table compares the two modes.

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

While observer mode is active, the SDK also sends an x-observer-mode: true header on its requests. The server uses it to record that this client is observing purchases rather than owning them.

APIs blocked in observer mode

Starting a purchase is blocked. So is every write flow that depends on owning the transaction. Hosted paywall purchase actions fail the same way and report an error through their callback or delegate.

Reads stay available: products, persons, entitlements, feature flags, and paywall assignments. Restores, queued analytics, and person attribute updates keep working too.

Switch ownership at runtime

Observer mode is not fixed when you construct the client. You can 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 or 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. A mid-purchase flip can therefore never leave that transaction unfinished with the store. Transactions that are already being processed when the call lands may also complete under the previous mode.

Switching at runtime means you never recreate the client. Recreating it would drop the native store connection, the caches, and the analytics queue.

Roll out behind a feature flag

Ownership has two halves: which SDK's UI starts a purchase, and which SDK finishes the resulting transaction. Decide both from one flag. Reading both halves 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 you present 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. Unlike observer mode, this flag is fixed at construction. To enable the SDK later, create a new client. That is cheap, because a disabled client 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 to 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 a paywall that is already visible arrive on the hook's callbacks (onError, onPreloadError), not 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 of failure apart:

  • Fallback-safe outcomes. Nothing was presented and no money moved, so showing the other paywall is always correct. These are not assigned, not initialized, presenter unavailable, disabled, and a resolve or presentation failure.
  • Purchase and restore failures. These arrive 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 that 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 the entitlement grants in Voidhash with the access your existing system grants. Investigate every mismatch before you continue. This step is what makes the switch safe.

Move purchase UI and ownership together

Put both behind one flag, as shown 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. Google Play refunds a transaction that is left unacknowledged after three days, so an ownership gap shows up as lost revenue rather than as an error.

Next steps