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:
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.
| Behavior | Observer mode | Owner mode (default) |
|---|---|---|
| Starts purchases | No | Yes |
| Observes store transactions | Yes | Yes |
| Syncs transactions to Voidhash | Yes | Yes |
| Finishes / acknowledges store transactions | No | Yes, after a successful sync |
| Reads products, persons, entitlements | Yes | Yes |
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 observingvoidhash.setReadOnly(false) // Voidhash now owns purchases
voidhash.setReadOnly(true) // back to observingThe 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:
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.