@vibemonetize/capacitor
v0.0.8
Published
Capacitor bridge for VibeMonetize: openCheckout/openBillingPortal (opens hosted Stripe URLs via @capacitor/browser instead of window.location, so the WebView app stays alive), registerCheckoutReturn (detects the checkout-success deep-link return via @capa
Downloads
75
Maintainers
Readme
@vibemonetize/capacitor
Capacitor bridge for VibeMonetize.
A Capacitor app runs a normal web bundle inside a native WebView, so
@vibemonetize/react (and the raw fetch-based API) already works there
unmodified for entitlement fetch, useFeature/useMeter gating, and usage/credit-bank
recording — fetch, localStorage, and React all run fine inside the WebView. The ONLY
seams that break are the leave-and-return-to-app payment flows:
<CheckoutButton>/<AccountButton> call window.location.assign(hostedUrl), which
navigates the WebView itself away to Stripe's hosted checkout/portal domain instead of
opening it in a system browser alongside your still-running app — on iOS/Android that
usually strands the user with no way back in.
This package fills exactly that gap. It does not reimplement entitlements, gating,
JWT verification, or usage metering — keep using @vibemonetize/react/
@vibemonetize/server for those; this package only opens/observes the payment round trip.
⚠️ Read this before shipping to iOS
Apple's App Store guidelines require digital goods and services consumed inside your app to go through Apple's In-App Purchase (IAP) system, not an external payment processor like Stripe. Routing a Stripe Checkout/billing-portal session through this package (or any external browser) does not exempt you from that rule — Apple review has rejected apps for exactly this pattern. It's generally lower-risk for:
- physical goods/services consumed outside the app (Guideline 3.1.3(a) — e.g. a physical-goods marketplace, or a service like a car ride),
- B2B/business tools where the app itself isn't the point of sale for an end consumer, or
- Android-only distribution (Google Play has historically been more permissive here, though its own policies are also evolving).
If your app sells subscriptions/credits/features to end users FOR USE INSIDE the app itself, talk to whoever owns your App Store compliance before using this package on iOS — this is a business/policy decision this package cannot make for you, and VibeMonetize does not offer an Apple IAP integration today.
Install
pnpm add @vibemonetize/capacitor
pnpm add @capacitor/browser @capacitor/app @capacitor/preferences@capacitor/core, @capacitor/browser, @capacitor/app, and @capacitor/preferences
are peerDependencies (optional) — this package never bundles them; it loads each one
lazily via dynamic import() only when a function that needs it is actually called, and
throws a typed CapacitorMonetizeError (browser_unavailable/app_plugin_unavailable)
if it's missing.
Quick start
1. Register your deep link with the OS
Stripe redirects the system browser to successUrl/cancelUrl when checkout/the
billing portal ends. Those need to be a deep link your Capacitor app is registered to
handle (a custom URL scheme, or a universal/app link) — configure this in
capacitor.config.ts and your native project (Info.plist / AndroidManifest.xml) per the
Capacitor deep linking guide. This
README assumes a custom scheme like com.myapp.app://.
2. Register the return listener once, at app boot
// main.tsx (Vite) or your app's entry point
import { registerCheckoutReturn } from "@vibemonetize/capacitor";
registerCheckoutReturn({
onReturn: () => {
// Refresh your entitlement token so newly-granted access shows up
// immediately. If you're using @vibemonetize/react, prefer
// `@vibemonetize/capacitor/react`'s `useCapacitorCheckout` instead —
// it wires this up to `useMonetizeContext().refresh()` for you.
},
});3. Bridge anonId/endUserId durability (recommended)
<MonetizeProvider> persists its anonId/endUserId in localStorage, which is
not guaranteed durable inside a WebView (iOS in particular can evict WKWebView
storage under disk pressure, and app updates can reset the WebView's storage origin).
Seed it from — and mirror it to — @capacitor/preferences (native platform storage)
instead, without modifying @vibemonetize/react at all:
// main.tsx, before rendering <MonetizeProvider>
import { bridgeLocalStorageWithPreferences } from "@vibemonetize/capacitor";
await bridgeLocalStorageWithPreferences();
ReactDOM.createRoot(document.getElementById("root")!).render(
<MonetizeProvider appId={appId} apiUrl={apiUrl} publishableKey={publishableKey}>
<App />
</MonetizeProvider>,
);4. Replace <CheckoutButton>/<AccountButton> with the Capacitor equivalents
Framework-agnostic:
import { openCheckout } from "@vibemonetize/capacitor";
const result = await openCheckout({
apiUrl,
publishableKey,
appId,
planSlug: "pro",
anonId, // or endUserId
successUrl: "com.myapp.app://checkout-success",
cancelUrl: "com.myapp.app://checkout-cancel",
});
if (result.outcome === "success") {
// await refresh();
}With @vibemonetize/react (recommended — fills in apiUrl/publishableKey/appId/
identity from <MonetizeProvider> automatically, and calls refresh() for you):
import { CapacitorCheckoutButton } from "@vibemonetize/capacitor/react";
<CapacitorCheckoutButton
planSlug="pro"
successUrl="com.myapp.app://checkout-success"
cancelUrl="com.myapp.app://checkout-cancel"
>
Upgrade to Pro
</CapacitorCheckoutButton>;or the lower-level hook:
import { useCapacitorCheckout } from "@vibemonetize/capacitor/react";
function UpgradeButton() {
const { openCheckout, pending } = useCapacitorCheckout();
return (
<button
disabled={pending}
onClick={() =>
void openCheckout({
planSlug: "pro",
successUrl: "com.myapp.app://checkout-success",
cancelUrl: "com.myapp.app://checkout-cancel",
})
}
>
Upgrade to Pro
</button>
);
}The billing portal
import { openBillingPortal } from "@vibemonetize/capacitor";
const token = await getToken(); // e.g. useMonetizeContext().getToken()
if (token) {
await openBillingPortal({
apiUrl,
publishableKey,
token,
returnUrl: "com.myapp.app://account",
});
}API
openCheckout(options): Promise<OpenCheckoutResult>
Creates a Stripe Checkout session via POST /v1/checkout-sessions (the same route,
auth, and request/response shapes <CheckoutButton> uses —
checkoutSessionRequestSchema/checkoutSessionResponseSchema from @vibemonetize/core),
then opens the returned hosted url with @capacitor/browser's Browser.open — never
window.location.assign. Resolves once the flow visibly ends:
type OpenCheckoutResult = {
outcome: "success" | "cancel" | "browser-closed";
returnUrl?: string; // present for "success"/"cancel"
};Never throws a raw error — every failure is a CapacitorMonetizeError (see below).
openBillingPortal(options): Promise<OpenBillingPortalResult>
Same pattern for the RFC 009 billing-portal route (POST /v1/billing-portal-sessions),
scoped to the caller's own entitlement JWT (token) rather than a client-named
endUserId/anonId — same as <AccountButton>'s "Manage billing".
registerCheckoutReturn({ onReturn, param?, value?, urlMatch?, closeBrowser? })
Registers a single, app-wide @capacitor/app appUrlOpen listener that detects the
checkout-success return (the same ?checkout=success convention
<CheckoutButton>/<CheckoutSuccessBanner> use on the web — see
packages/react/src/checkoutSuccess.ts) and invokes onReturn. Call this once at app
boot, independent of any particular openCheckout() call. Returns
{ remove: () => void } to unsubscribe.
createPreferencesStorage() / seedLocalStorageFromPreferences() / mirrorLocalStorageToPreferences() / bridgeLocalStorageWithPreferences()
A durable key/value adapter backed by @capacitor/preferences, plus a
seed-then-mirror bridge specifically for <MonetizeProvider>'s localStorage-persisted
anonId/endUserId keys — see Quick start §3.
Errors
Every function in this package throws/rejects a CapacitorMonetizeError — never a raw
Error or native-plugin exception:
export class CapacitorMonetizeError extends Error {
readonly code: CapacitorMonetizeErrorCode;
}code is one of @vibemonetize/core's {code,message} error catalog (not_found,
forbidden, validation_failed, mode_mismatch, ...) when the api itself rejected the
request, plus four bridge-only codes: network_error, invalid_response,
browser_unavailable, app_plugin_unavailable. See the TSDoc on
CapacitorMonetizeErrorCode in src/errors.ts for exactly when each fires.
Testing
This package's own test suite (pnpm test) mocks every @capacitor/* plugin — there is
no real native runtime or device involved. Verifying actual behavior on a real iOS/
Android device or simulator (deep-link delivery, Browser.open chrome, WebView storage
eviction) is out of scope for this repo's automated tests and should be part of your
own app's manual/e2e QA before shipping.
Follow-up (not implemented here)
<CheckoutButton>/<AccountButton> in @vibemonetize/react still call
window.location.assign unconditionally. Adding an optional openExternalUrl override
prop to <MonetizeProvider> (defaulting to window.location.assign, overridable to
e.g. (url) => openCheckout(...)/Browser.open) would let the existing bundled
<CheckoutButton>/<AccountButton> work correctly inside a Capacitor WebView
automatically, instead of requiring Capacitor apps to swap in this package's separate
<CapacitorCheckoutButton>/openCheckout calls. That's a change to
@vibemonetize/react itself (out of this package's scope) — flagged here for whoever
owns that package next.
