Migrate from Plandalf v1
Keep your billing rules. Replace v1 dispatchers with an access check, an approved offer, and a billing sync callback. The gate opens checkout only when access is denied.
Your backend owns tiers, prices, eligibility, and entitlements. The SDK owns the paywall lifecycle. All values and backend adapters below are examples.
Warning. Check the React package version The current organization-hosted JavaScript SDK supports the v2 gate contract below. The public React package is still 1.1.6 and does not guarantee these v2 hooks and runtime constants. Use a verified compatible React build, or use the hosted SDK with the application-owned readiness hook and compatibility helper below. Loading the new script does not make a v1 workflow dispatcher compatible.
Old code versus new code
Before: dispatch a legacy paywall event
if (window.plandalf.isWorkflowActive("automation")) {
window.plandalf.dispatch("automation");
}
window.plandalf.on("checkout-success", async () => {
await refreshSubscription();
});
After: check access, open the paywall if needed, continue
Use the existing application’s billing.features.automation check. Your backend supplies an approved offerSlug for this account’s tier, service, and cycle. React inherits signed identity from PlandalfProvider.
async function enable() {
const sdk = await new Promise(resolve => plandalf("ready", resolve));
sdk.addGate("automation", offerSlug,
(fresh = billing) => fresh.features.automation === true);
const { access, status, error } = await sdk.gate("automation", syncBilling, {
user: customerJwt, mode: "test", session: null
});
if (access) await enableAutomation();
else if (status === "error") onError(error);
else if (status === "purchased") onPending();
}
// The UI awaits enable(), catches action errors, and blocks duplicate clicks.import { useGate, GateResultStatus } from "@plandalf/react";
export function AutomationButton({
billing, offerSlug, syncBilling, enableAutomation, onPending, onError
}) {
const { trigger, checking, access, result } = useGate(
"automation", offerSlug,
// true allows access; false opens this offer when trigger() is called.
(fresh = billing) => fresh.features.automation === true
);
const pending = result?.status === GateResultStatus.Purchased && !access;
async function enable() {
// After checkout, syncBilling returns fresh values for the same check.
const { access, status, error } = await trigger(syncBilling, {
mode: "test"
});
if (access) {
try { await enableAutomation(); }
catch (error) { onError(error); }
return;
}
if (status === GateResultStatus.Error) onError(error);
// Purchased but not entitled yet: refresh billing, not checkout.
if (status === GateResultStatus.Purchased) onPending();
}
return (
<button disabled={checking || pending} onClick={enable}>
Enable automation
</button>
);
}"automation" names your app’s gate; offerSlug selects checkout; the callback decides access. useGate() previews access without opening checkout. trigger() runs check → checkout if blocked → sync → the same check. No separate present() or listener. Hook state and result shapes.
Supply identity once
import { PlandalfProvider } from "@plandalf/react";
import { AutomationButton } from "./AutomationButton";
export function MigrationApp({
apiBase, customerJwt, billing, offerSlug, syncBilling,
enableAutomation, onPending, onError
}) {
return (
<PlandalfProvider apiBase={apiBase} user={customerJwt}>
<AutomationButton
billing={billing}
offerSlug={offerSlug}
syncBilling={syncBilling}
enableAutomation={enableAutomation}
onPending={onPending}
onError={onError}
/>
</PlandalfProvider>
);
}Pass the same billing, offer, and callback props throughout. JWTs come from your authenticated backend—not API secrets. Replace the provider’s user token when switching accounts. The identity lifecycle explains token expiry, logout, and session continuity.
Sync billing, then re-check the same gate
Define this callback in the parent owning billing, and pass it to the button:
async function syncBilling() {
// Your backend verifies provider state; your parent owns billing state.
const fresh = await reloadBilling();
setBilling(fresh);
return fresh;
}reloadBilling reconciles verified provider state through your backend; setBilling replaces parent state/cache. The SDK re-checks the returned snapshot, not a guessed upgrade tier.
If your existing sync callback only updates a cache, also return the fresh billing values. Returning nothing makes the example’s check fall back to the old snapshot.
| Outcome | Continue? |
|---|---|
| Existing access | Yes; no checkout or sync. |
| Checkout complete, fresh check true | Yes; run the protected backend action. |
| Checkout dismissed | No; leave billing alone. |
| Check, checkout, or sync error | No; report the failure. |
| Checkout complete, fresh check false | No; refresh pending billing, not checkout. |
onPending shows a billing-refresh control; update the parent props when access arrives. Your protected backend action must independently authorize the feature. A browser result is not payment proof.
Standalone upgrade button (no protected action)
After: one checkout, one promise
For upgrade-only buttons, await present() and refresh billing on completion. React inherits provider identity; plain JavaScript uses the install stub.
async function upgrade() {
const sdk = await new Promise(resolve => plandalf("ready", resolve));
const { offerSlug, customerJwt } = await billing.prepareUpgrade();
if (!offerSlug || !customerJwt) throw new Error("No eligible upgrade");
const result = await sdk.present(offerSlug, {
user: customerJwt, mode: "test", session: null
});
if (result.status !== "complete") return;
await billing.reconcile();
await onUpdated(await billing.refresh());
}
// Your UI calls upgrade(), catches failures, and disables duplicate clicks.import { usePresent, FlowResultStatus } from "@plandalf/react";
export function UpgradeButton({ offerSlug, onComplete, onError }) {
const { present, presenting } = usePresent();
async function upgrade() {
try {
const result = await present(offerSlug, { mode: "test" });
if (result.status === FlowResultStatus.Complete) await onComplete(result);
} catch (error) {
onError(error);
}
}
return (
<button disabled={presenting} onClick={upgrade}>Upgrade</button>
);
}onComplete reconciles through your backend. billing.prepareUpgrade, reconcile, and refresh are app adapters, not SDK methods. Reject ineligible, already-active, and pending upgrades server-side.
Dismissal does nothing. Reconciliation failure means refresh/retry verification—not another payment. Optional live events.
Older hosted builds: protected batch action
Prepare the route, then run a named gate
Compatibility code for older hosted SDKs, not the recommended v2 React hook. Keep explicit backend verification until your installed build supports post-sync checks.
plandalf("ready", (sdk) => {
const button = document.getElementById("run-batch");
button.disabled = false;
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
try {
const taskIds = [...selectedTaskIds];
const request = { quantity: taskIds.length };
// Your authenticated backend validates the request and chooses an offer.
const prepared = await billing.prepareBatch(request);
const result = await runVerifiedGate({
sdk, name: "batch-limit", ...prepared,
readAccess: () => billing.canRunBatch(request),
syncBilling: () => billing.reconcile()
});
if (result.state === "allowed") await runBatch(taskIds);
else showBillingState(result.state);
} catch (error) {
showBillingError(error);
} finally {
button.disabled = false;
}
});
});import { useRef, useState } from "react";
import { usePlandalfSdk } from "./use-plandalf-sdk";
import { runVerifiedGate } from "./verified-gate";
export function BatchUpgradeButton({ billing, selectedTaskIds, onAllowed }) {
const sdk = usePlandalfSdk();
const busy = useRef(false);
const [state, setState] = useState("idle");
const [error, setError] = useState("");
async function run() {
if (!sdk || busy.current) return;
busy.current = true;
setState("checking");
setError("");
const taskIds = [...selectedTaskIds];
const request = { quantity: taskIds.length };
try {
const prepared = await billing.prepareBatch(request);
const result = await runVerifiedGate({
sdk, name: "batch-limit", ...prepared,
readAccess: () => billing.canRunBatch(request),
syncBilling: () => billing.reconcile()
});
setState(result.state);
if (result.state === "allowed") await onAllowed(taskIds);
} catch (error) {
setState("error");
setError(error.message || "Could not check billing");
} finally {
busy.current = false;
}
}
return (
<>
<button disabled={!sdk || state === "checking"} onClick={run}>
Run batch
</button>
{state === "pending" && (
<p role="status">Confirming your upgrade. Refresh access before retrying.</p>
)}
{state === "manual" && (
<p role="status">Use your existing billing or support page.</p>
)}
{state === "error" && (
<p role="alert">{error || "Checkout could not open"}</p>
)}
</>
);
}For React, copy this application-owned readiness hook into use-plandalf-sdk.js, and copy the shared runVerifiedGate helper below into verified-gate.js. Load the organization SDK once in your app shell. onAllowed(taskIds) calls your protected backend action; it is not an entitlement grant.
import { useEffect, useState } from "react";
export function usePlandalfSdk() {
const [sdk, setSdk] = useState(null);
useEffect(() => {
let active = true;
window.plandalf("ready", instance => {
if (active) setSdk(() => instance);
});
return () => { active = false; };
}, []);
return sdk;
}billing, runBatch, and the UI helpers are application adapters. runVerifiedGate is implemented below. Render the button disabled until your SDK and adapters are ready. Capture the task selection for the operation; do not allow it to change during billing. The runBatch backend must enforce the same access and quantity rules again.
The steps are: load trusted billing values → choose the route for this action → run the gate → reconcile billing → verify access → continue. Do not add a second present() call after gate(); the gate already owns checkout.
First, find the four kinds of upgrade button
This is an audit of your existing buttons, not product stock or inventory. Find every old dispatch() call and record the action it protects.
| Action | Check before buying | Purchase route when blocked |
|---|---|---|
| Run a batch | Used tasks + requested tasks fit the limit | Eligible capacity add-on or approved higher plan. |
| Remove branding | This service includes unbranded output | Branding offer for this service and billing cycle. |
| Enable automation | This account has the requested feature | Eligible automation add-on or plan upgrade. |
| Change plan | Selected tier is permitted for this account | The selected tier’s exact price and published offer. |
Several buttons can share one gate. Separate products/services can have separate subscriptions. Keep the original unit of measurement: tasks are not necessarily recipients, seats, or videos.
Use each approved route with a check for that action. These are alternative hook definitions inside the component, not another application hook:
const branding = useGate("branding", offers.branding,
// Keep this service's branding entitlement.
(fresh = billing) => fresh.features.unbranded === true);
const batch = useGate("batch-limit", offers.capacity,
// Include the requested quantity, not just the current usage.
(fresh = billing) =>
fresh.usage.tasks + quantity <= fresh.limits.tasks);
const business = useGate("business-plan", offers.business,
// Use the stable tier key, not its display label.
(fresh = billing) => fresh.tier === "business");Import useGate from @plandalf/react. Use the same trigger(syncBilling) pattern to continue only when allowed. Validate quantity server-side; freeze the requested selection while its gate runs and enforce usage atomically. After plan checkout, refresh/navigate—don’t submit another subscription change.
The batch example is a cumulative quota. For a per-batch cap, check quantity <= fresh.limits.tasks plus the existing batch-feature entitlement instead. Don’t turn a per-action limit into monthly usage.
Values your backend needs
Read these from your authenticated account, subscription records, and configured catalog. Do not accept the account, tier, provider IDs, amount, or eligible options as trusted browser input.
{
"accountId": "team_example",
"service": "core",
"mode": "test",
"gateway": "stripe",
"tier": "pro",
"cycle": "monthly",
"subscriptionStatus": "active",
"stripeCustomerId": "cus_example",
"stripeSubscriptionId": "sub_example",
"canManageBilling": true,
"legacy": false,
"planLocked": false,
"usage": { "tasks": 95 },
"limits": { "tasks": 100 },
"features": { "unbranded": false, "automation": false },
"eligiblePriceKeys": [
"capacity-500-monthly", "branding-monthly",
"automation-monthly", "business-monthly"
]
}The provider IDs must belong to that account, service, Stripe account, and environment. subscriptionStatus does not itself grant access: compute features and limits using your existing trial, grace-period, cancellation, and legacy rules. Refresh them after verified provider changes.
This guide keeps the application’s features shape. The SDK does not require that name: GateResult.entitlements holds the whole snapshot returned by syncBilling.
One offer, then select it
Start with one tested route. Configure its product, price, and subscription-update behavior in Plandalf; this server-owned record points to that published offer.
export const automationOffer = {
key: "automation-monthly",
service: "core",
mode: "test",
gateway: "stripe",
tier: "pro",
cycle: "monthly",
amount: 1900, // AUD 19.00 in minor units.
currency: "aud",
providerPriceId: "price_automation_example",
offerSlug: "enable-automation",
operation: "update-subscription",
enabled: false // Enable only after a provider-backed test.
};Select the offer on your backend
Read billing from the authenticated account—not request-body tier or provider IDs. Then match the record directly:
export function automationOfferFor(billing, automationOffer) {
// Keep unsupported accounts on the existing billing/support page.
if (!billing.canManageBilling || billing.legacy || billing.planLocked) {
return null;
}
if (!["active", "trialing"].includes(billing.subscriptionStatus)) {
return null;
}
if (!billing.stripeCustomerId || !billing.stripeSubscriptionId) {
return null;
}
// This offer is only for the exact service, provider, tier, and cycle.
if (billing.service !== automationOffer.service) return null;
if (billing.mode !== automationOffer.mode) return null;
if (billing.gateway !== automationOffer.gateway) return null;
if (billing.tier !== automationOffer.tier) return null;
if (billing.cycle !== automationOffer.cycle) return null;
if (!automationOffer.enabled) return null;
if (!billing.eligiblePriceKeys.includes(automationOffer.key)) return null;
return automationOffer.offerSlug;
}Call automationOfferFor(billing, automationOffer) on the backend and return its result as offerSlug. null means use existing billing/support; don’t render a checkout trigger or guess a different offer.
The record is trusted, validated configuration: require a real offer/price, non-negative integer amounts, correct currency/cycle, and a supported subscription operation before enabling it. Verify provider IDs belong to the authenticated account, service, Stripe account, and environment.
Pass that offer into the gate
import { AutomationButton } from "./AutomationButton";
export function SelectedAutomationButton({ offerSlug, ...props }) {
if (!offerSlug) {
return (
<a href="/billing">Manage billing</a>
);
}
return (
<AutomationButton
{...props}
offerSlug={offerSlug}
/>
);
}Import your AutomationButton above. The parent supplies billing and callbacks. Users already entitled can use the normal protected action without a checkout offer.
Use the same pattern for other buttons
The earlier gate checks contain the feature rules; offer selection is separate:
| Button | Gate check | Offer must provide |
|---|---|---|
| Batch | usage.tasks + quantity <= limits.tasks | Enough capacity for that exact request. |
| Branding | features.unbranded === true | Unbranded access for this service/cycle. |
| Automation | features.automation === true | The requested automation entitlement. |
| Business plan | tier === "business" | The approved Business price and update path. |
For example, 95 used + 5 requested fits a 100-task limit; +10 needs capacity; +600 exceeds a 500-task offer. Validate quantity and select sufficient capacity server-side. Never open an offer that cannot cover the request.
Create an explicit record for each supported combination. Unknown tiers, cycles, providers, and actions stay on existing billing/support. A plan-name label is display-only; the stable tier determines access.
These records don’t implement billing changes. Do not send amount or operation in SDK metadata to change the subscription. Test each offer’s actual provider behavior; do not assume the base-plan change path can add every kind of add-on.
Use the gate, then verify access
The current v2 examples above use the SDK directly. Older builds need an explicit backend re-check:
Older-build compatibility helper
Your authenticated preparation endpoint returns { decision, customerJwt }. For this compatibility helper, decision is { state: "checkout", offerSlug }, { state: "allowed" }, or { state: "manual" }. Sign a fresh, short-lived customer JWT on your backend; see signed identity. Associate it with the logged-in account and this route’s service/subscription.
readAccess asks your backend whether this exact requested action is now allowed. syncBilling requests verified provider reconciliation; it must not trust a browser transaction payload to grant access.
export async function runVerifiedGate({
sdk, name, decision, customerJwt, syncBilling, readAccess
}) {
if (decision.state === "manual") return decision;
// A failed preflight read rejects before opening checkout.
const accessBefore = await readAccess();
if (accessBefore === true) return { state: "allowed" };
if (decision.state !== "checkout") return { state: "pending" };
if (!customerJwt) throw new Error("Signed customer identity is required");
sdk.addGate(name, decision.offerSlug, () => readAccess());
let syncFailed = false;
const result = await sdk.gate(name, async () => {
try { await syncBilling(); }
catch { syncFailed = true; }
}, { user: customerJwt, mode: "test", session: null });
if (result.status === "error") return { state: "error" };
if (syncFailed) return { state: "pending" };
if (!result.access) return { state: result.status === "purchased" ? "pending" : "dismissed" };
return { state: (await readAccess()) === true ? "allowed" : "pending" };
}The helper uses per-call identity and a fresh test session. Serialize billing actions across the account and disable triggers while a gate runs; gate registrations are shared on an SDK instance. On account switching, reset identity and session state.
Warning. Gate completion is not verified entitlement Older hosted/package builds can return access: true after completion even if sync throws, do not re-check access, and can fall through to checkout after a throwing check. The current hosted v2 SDK denies access on errors and reruns the registered check with fresh sync values. The public React 1.1.6 package does not guarantee that contract. This compatibility helper retains explicit backend verification for either build; the protected endpoint still authorizes independently.
Show a visible “Confirming your upgrade” state for pending, with a retry/refresh control. Retry reconciliation or access verification—not checkout. Dismissal, errors, and delayed provider events must not unlock a feature.
What happens to the old events?
The current SDK has checkout lifecycle events. It does not provide a compatible v1 workflow dispatcher or emit the old checkout-success event. Replacing the script without replacing those consumers will break them.
| Old dependency | Migration |
|---|---|
isWorkflowActive() / dispatch() | Your existing access check + selected offer + useGate() (or addGate() / gate() in JavaScript). |
Global checkout-success listener | Await the gate for a protected action, or await one present() handle for a standalone checkout. |
| Checkout analytics | Observe that handle’s start, purchase, complete, and cancel events. |
| Subscription renewals, cancellations, failed payments | Keep server-side provider events and entitlement reconciliation. Browser events cannot replace them. |
| v1 usage/reporting events | Audit and migrate separately; this guide does not recreate those APIs. |
For a standalone checkout, not one already opened by a gate:
const handle = sdk.present("upgrade-business", {
user: customerJwt, mode: "test", session: null
});
const observe = (async () => {
for await (const event of handle.events) {
recordCheckoutAnalytics(event.type);
}
})();
const result = await handle;
await observe;
if (result.status === "complete") await billing.reconcile();
// Refresh verified backend access before changing paid UI state.Use one event consumer per handle and fan out from that consumer if needed. A purchase event can occur before final completion, including an upsell; it is not proof that your application has provisioned access. See live events.
Existing Stripe subscribers need an update path
Do not replace an existing subscriber’s paywall with a generic new-subscription checkout. The current backend has a Stripe subscription-change path: an upgrade session with an existing subscription goes through preview and commit. A signed JWT’s subscription claim can derive that upgrade intent. This is implemented behavior, not proof that every example add-on route is supported or migration-ready.
The current commit dispatcher handles Expansion only. Other change signals need implementation/testing before rollout. The Stripe adapter updates the base item and retains existing add-ons; adding a new add-on needs a verified path of its own.
For a verified existing-subscription route, issue identity containing the correct association:
{
"sub": "usr_example",
"grp": "team_example",
"customer": "cus_example",
"subscription": "sub_example",
"email": "jane@example.com"
}Sign it with the intended organization’s API-key secret, HS256, a kid, and an expiry; never send the secret to the browser. The examples are payloads, not signed tokens. Do not reuse a subscription claim for a route intended to create a separate subscription.
Test that the intended subscription item changes, unrelated add-ons survive, quantity is correct, and proration/payment timing matches the UI. Changing a base plan and adding an add-on are different operations. See the Stripe migration checks before enabling a route.
Warning. Verify the actual Stripe environment before paying Connect and verify the organization’s Stripe test integration first. The current session-creation path can fall back to the live Stripe integration when the test integration is missing; mode: "test" alone is not a safety guarantee. Test-mode overrides also have server-side access restrictions. Confirm the actual provider account, keys, customer, and prices before attempting payment.
For an external app requesting test checkout, pass { mode: "test" } and verify that the offer is configured for test mode or the caller is authenticated as a member of its organization. The inspected SDK session endpoint does not authorize test mode from a JWT claim. Verify the selected Stripe test connection before payment. For customer identity, use your application’s account ID in sub; reserve customer for a verified Stripe customer ID and sign with the checkout organization’s key. See the identity and test checkout requirements.
Keep your provider webhook receiver and price-to-tier mapping. Stripe subscription changes and renewals are asynchronous; handle payment outcomes and subscription changes on your backend, with Stripe signature verification. See Stripe’s subscription event guidance. Plandalf HTTP actions/webhooks are a separate handoff mechanism, not a replacement for those provider events.
Warning. Replace the buying surface, not the usage-billing engine Keep any v1 usage-event publishing, deletion/corrections, and usage-fee reads until each has a verified replacement. Browser gates do not meter consumption or invoice it. A successful paywall migration does not make those backend APIs compatible.
Test and roll out one action at a time
For every supported service, tier, cycle, and provider, verify:
- Existing access: no checkout; the normal protected action succeeds.
- Blocked access: exact approved price and offer; unknown, disabled, or duplicate routes stay on the old flow.
- Quantity boundaries: below, exactly at, and above the limit; concurrent actions cannot exceed it.
- Existing subscriber: intended item update, no duplicate subscription, correct quantity and proration.
- Dismissal, payment failure, or missing identity: no early unlock; controls recover.
- Sync failure or delayed fulfilment: pending state; retry does not open or charge again.
- Verified provider change: correct account and entitlement mapping; the protected backend action actually succeeds.
- Renewal, cancellation, downgrade, failed payment, and account switching: application access stays consistent.
Keep the old buying surface behind a rollback flag. Remove each legacy event listener only after every consumer using it has migrated. A successful frame render or passing example test is not a completed customer migration.
Next steps
- Gates for the actual SDK gate contract.
- Live events for checkout observation.
- Stripe for provider migration checks.
- Keep catalog API calls scoped to the organization when upserting products and prices.
- First checkout checks before switching traffic.