@billingbear/purchases-js
v0.7.0
Published
Web subscription management SDK for BillingBear — gate premium features via Stripe web checkout. Same REST client as @billingbear/purchases but built on fetch + localStorage (no react-native-iap / AsyncStorage).
Downloads
389
Readme
@billingbear/purchases-js
Web subscription SDK for BillingBear — gate premium
features in any web app and take payment through Stripe Checkout. Same REST
backend as the React Native SDK (@billingbear/purchases), but built on plain
fetch + localStorage with SSR guards. No react-native-iap, no
AsyncStorage, no native modules — it runs in the browser and during
Next.js / SSR server render without throwing.
npm install @billingbear/purchases-jsQuick start
import { Purchases } from '@billingbear/purchases-js';
// Call once at app startup. Resolves (and persists) an anonymous appUserId
// in localStorage if you don't pass one.
const info = await Purchases.configure({ apiKey: 'pk_live_...' });
if (info.isPremium) unlockApp();Gating premium
if (await Purchases.isPremium()) { /* ... */ }
// Or check a specific entitlement identifier:
if (await Purchases.checkEntitlement('pro')) { /* ... */ }
// Latest snapshot (re-fetches entitlements):
const info = await Purchases.getCustomerInfo();Stripe web checkout
// Creates a Checkout session and redirects the browser to Stripe.
// successUrl / cancelUrl default to the current page.
await Purchases.startWebCheckout({ priceId: 'price_123' });
// Or create the session yourself and control the redirect:
const { url } = await Purchases.createCheckoutSession({
priceId: 'price_123',
successUrl: 'https://app.example.com/welcome',
cancelUrl: 'https://app.example.com/pricing',
});
window.open(url, '_blank');Offerings → checkout
Fetch your offerings and check out the right package without hard-coding the
Stripe price id. For the stripe provider a product's productId IS the Stripe
Price id; the stripePriceId helper pulls it out for you:
import { Purchases, stripePriceId } from '@billingbear/purchases-js';
const { offerings } = await Purchases.getOfferings();
const current = offerings.find((o) => o.isCurrent) ?? offerings[0];
const monthly = current?.packages.find((p) => p.identifier === 'monthly');
const priceId = stripePriceId(monthly); // -> "price_123" | null
if (priceId) {
await Purchases.startWebCheckout({ priceId }); // redirect to Stripe Checkout
}Each OfferingProduct also carries display price metadata when the backend has
it (priceString, price, currencyCode, billingPeriod, trialPeriod,
localizedPrices) so you can render a paywall with the real price.
Attribution
The same attribution / MMP setters as the React Native SDK are available — one
line each, writing the reserved $-prefixed subscriber attributes:
await Purchases.setAdjustID('adjust-id');
await Purchases.setAppsflyerID('appsflyer-id');
await Purchases.setCampaign('spring_sale');
// also: setFBAnonymousID, setMparticleID, setOnesignalID, setAirshipChannelID,
// setCleverTapID, setMixpanelDistinctID, setFirebaseAppInstanceID,
// setMediaSource, setAdGroup, setAd, setKeyword, setCreativeIdentity
// Link the anonymous user to your own user id (merges entitlements server-side).
await Purchases.login('your-user-id');
// Reset to a fresh anonymous user.
await Purchases.logout();Paywalls
The SDK ships a built-in paywall renderer — compiled-to-vanilla DOM with inline styles, zero framework dependencies (no React, no Tailwind, no CSS to import). Fetch a paywall and present it as a modal:
import { Purchases, presentPaywall } from '@billingbear/purchases-js';
const paywall = await Purchases.getPaywall('main');
if (paywall) {
const result = await presentPaywall({
paywall,
variables: { 'user.firstName': 'Marco' }, // fills {{ user.firstName }} in the copy
// The chosen product identifier IS the Stripe Price id by default; map it
// explicitly if your dashboard product identifiers differ:
// resolvePriceId: (productId) => priceMap[productId],
});
// { outcome: 'purchased' | 'dismissed' | 'restored', productId? }
if (result.outcome === 'purchased') unlock();
}presentPaywall mounts a fullscreen overlay (high z-index, body scroll-lock),
renders all five layouts (vertical-features, horizontal-products,
comparison-table, minimal, hero-image) with solid/gradient/image/video/
lottie backgrounds, the trial badge, urgency countdown, social proof, and the
optional survey overlay — faithful to the dashboard's hosted page.
Lottie backgrounds (optional)
Paywalls with a lottie background animate via lottie-web,
which the SDK loads on-demand (a guarded dynamic import) so it stays a
zero-required-dependency package. lottie-web is declared as an optional
dependency / optional peer — install it only if you ship lottie paywalls:
npm install lottie-webWhen the module isn't present (or the animation fails to load), the renderer falls back to the paywall's configured poster image, so a lottie paywall always looks intentional without the extra dependency.
Redirect note: the CTA calls
Purchases.startWebCheckout, which redirects the browser to Stripe. When that happens the page navigates away, so the promise may never resolve — the user lands on yoursuccessUrlinstead. PasssuccessUrl/cancelUrlto control where they return.
Inline embed
To embed the paywall inside your own page instead of a modal, use
renderPaywall — same options, no overlay, outcome delivered via onOutcome:
import { renderPaywall } from '@billingbear/purchases-js';
const controller = renderPaywall(document.getElementById('paywall')!, {
paywall,
onOutcome: (r) => { if (r.outcome === 'purchased') unlock(); },
});
// controller.dismiss() / controller.destroy()Events
Both entry points emit the standard paywall event stream (identical to the React Native SDK) — subscribe for funnel analytics:
import { addPaywallEventListener } from '@billingbear/purchases-js';
const off = addPaywallEventListener((e) => analytics.track(e.type, e));
// paywall_impression | paywall_dismiss | paywall_purchase_started/completed/failed
// | paywall_custom_action | paywall_survey_responseExposures are recorded automatically: viewed at mount, dismissed / purchased
at the outcome. Survey answers also write the $survey_<id> subscriber attribute
for audience targeting.
Headless / custom renderer
getPaywall tags the request as platform: 'web' and passes the current
appUserId for A/B variant assignment, recording a best-effort viewed
exposure. If you render the paywall yourself, opt out and record the funnel
manually:
const paywall = await Purchases.getPaywall('main', { recordExposure: false });
if (paywall) {
// …your renderer…
Purchases.recordPaywallExposure(paywall, 'viewed');
Purchases.recordPaywallExposure(paywall, 'dismissed'); // or 'purchased'
}recordPaywallExposure is fire-and-forget — it never awaits or throws, and
forwards the paywall's assigned experiment id + variant automatically. The
low-level building block renderPaywallDocument(container, document, options)
is also exported if you want the DOM without the modal/telemetry shell.
Customer Center
A drop-in, themeable in-app subscription-management screen for your end users
— the web counterpart of the React Native <CustomerCenter />. Compiled-to-vanilla
DOM with inline styles, zero framework dependencies. Present it as a modal:
import { presentCustomerCenter } from '@billingbear/purchases-js';
const result = await presentCustomerCenter();
// { outcome: 'closed', lastAction?: { outcome, subscriptionId } }On open it loads the Customer Center payload (subscriptions + the themeable
config) for the tracked appUserId, renders each subscription with its
server-driven action buttons, and wires them to the SDK:
- manage_in_store → opens the store / billing-portal URL in a new tab
- cancel / cancel_at_period_end → optional cancellation survey, then a win-back offer (managed Stripe/Paddle subs) or a retention prompt, before the cancel POST
- reactivate → re-enables a period-end cancellation
- request_refund → confirm, then request a refund
- change_plan → switches to a target product, or falls back to support
Support links, custom sections and deep links from the config render below, and
virtual-currency balances (best-effort) show in a "Balances" group. Theming and
copy (localizations) come entirely from the dashboard config; pass a
themeOverride to force a mode/accent per mount. After every mutation the
payload is re-fetched so the cards reflect the new state.
await presentCustomerCenter({
appUserId: 'user-123', // defaults to the tracked Purchases.appUserId
themeOverride: { mode: 'dark' },
});Inline embed
To embed the Customer Center inside your own page instead of a modal, use
renderCustomerCenterInline — same options, no overlay, returns a controller:
import { renderCustomerCenterInline } from '@billingbear/purchases-js';
const controller = renderCustomerCenterInline(document.getElementById('cc')!, {
onClose: () => hide(),
onAction: (outcome, subId) => analytics.track('cc_action', { outcome, subId }),
});
// controller.destroy()The lower-level building blocks are also exported: Purchases.getCustomerCenter()
returns the raw payload, and renderCustomerCenter(container, data, options)
renders the DOM with your own transport if you want to drive it headlessly.
Rendered components
The SDK also ships real, embeddable React components — the same renderer the
hosted /p/{slug} page uses, packaged for any web app (and react-native-web /
Electron, since they use only DOM + inline styles — no Tailwind, no CSS file, no
Next.js). react is an optional peer dependency: install it only if you use
these components.
npm install react react-dom # peer deps for the components
npm install lottie-web # only if you use a Lottie background<Paywall>
Renders the full PaywallDocument with every background (solid / gradient /
image / video / lottie) and component block (features, social proof, "how it
works" timeline, tier tabs, free-trial switch, comparison table, products,
urgency countdown), and wires the purchase button to Stripe checkout.
import { Paywall } from '@billingbear/purchases-js';
// Fetch + render by identifier (calls Purchases.getPaywall under the hood):
<Paywall identifier="main" projectName="Acme" onClose={() => setOpen(false)} />
// Or render a document you already have, with real Stripe prices merged in:
<Paywall
document={doc}
prices={[
{ productIdentifier: 'yearly', priceId: 'price_123', formattedPrice: '$79.99', recurringInterval: 'year' },
]}
collectEmail
onCheckout={async (productId, email) => { /* your checkout */ }}
/>The default checkout opens a Stripe Checkout session via
Purchases.startWebCheckout using the matched price's priceId (or the product
identifier itself when no prices are supplied). Pass onCheckout to override.
Video backgrounds render with a native <video>; Lottie backgrounds load
lottie-web lazily (poster fallback if it isn't installed).
<CustomerCenter>
A drop-in, themeable subscription-management screen for end users (RevenueCat Customer Center parity) built from the data the SDK fetches. Renders each subscription with its server-driven action buttons and wires them to the SDK: manage-in-store, cancel (with optional survey + win-back offer + retention step), reactivate, request-refund and change-plan, plus support links and virtual-currency balances.
import { CustomerCenter } from '@billingbear/purchases-js';
<CustomerCenter onClose={() => setOpen(false)} />Both components are framework-agnostic React — drop them in a modal, a route, or a side panel.
RevenueCat migration
Swap the import and most code keeps working:
// - import Purchases from '@revenuecat/purchases-js';
import Purchases from '@billingbear/purchases-js/revenuecat';
await Purchases.configure({ apiKey: 'pk_live_...', appUserID: 'user-123' });
const info = await Purchases.getCustomerInfo();
if (info.entitlements.active['pro']) unlockApp();
// Web purchase (no store package on the web):
await Purchases.purchaseWeb({ priceId: 'price_123' });SSR notes
All localStorage access is guarded for typeof window === 'undefined' and
falls back to an in-memory store, so calling configure / getCustomerInfo
during server render is safe. startWebCheckout skips the window.location
redirect on the server and returns the session instead.
License
MIT
Electron / desktop
The SDK runs in three JS contexts with no code change:
- Website / Electron renderer (Chromium): works out of the box —
localStoragepersists the anonymous user,startWebCheckout()redirects in-page. - Electron main process (Node): there's no
window/localStorage, so inject a persistent store and use the desktop checkout flow (open the system browser, then poll). - Next.js / SSR: safe to import; storage degrades to in-memory on the server.
// Electron MAIN process
import { app, shell } from 'electron';
import Store from 'electron-store';
import { Purchases, setStorageAdapter } from '@billingbear/purchases-js';
const store = new Store();
setStorageAdapter({
getItem: (k) => (store.get(k) as string) ?? null,
setItem: (k, v) => store.set(k, v),
removeItem: (k) => store.delete(k),
});
await Purchases.configure({ apiKey: 'pk_live_...' });
// Desktop checkout: open the browser, then wait for the purchase to land.
const { url, sessionId } = await Purchases.getCheckoutUrl({ priceId: 'price_123' });
await shell.openExternal(url);
const info = await Purchases.awaitCheckout(sessionId); // polls until complete/expired
if (info.isPremium) unlockPremium();You can also pass the adapter inline: Purchases.configure({ apiKey, storage: myAdapter }).
For a custom-protocol deep link back into the app, pass successUrl: 'myapp://checkout/done' to getCheckoutUrl.
