npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

base44-revenuecat

v1.7.2

Published

In-app purchases and subscriptions for Base44 apps published as native iOS and Android apps with Despia. Query products with real StoreKit and Google Play Billing prices, launch RevenueCat native paywalls, check entitlements, and verify subscribers server

Readme

Base44 In-App Purchases & Subscriptions: RevenueCat for Base44 iOS and Android apps

Sell real App Store and Google Play subscriptions from your Base44 app. This package connects a Base44 app, published as a native iOS and Android app with Despia, to Apple StoreKit and Google Play Billing through the RevenueCat SDK that Despia compiles into your binary. Query products with live store pricing, launch native paywalls, run purchases, check entitlements, and verify subscribers server-side in Base44 backend functions. Promise-based, three-letter-simple, zero native code, zero webhooks.

on npm · tests passing · TypeScript types · Apache-2.0 licensed · zero dependencies

For Base44 makers shipping a real iOS and Android app: monthly and annual subscriptions, one-time unlocks, consumable credits, free trials and introductory offers, all billed by Apple and Google so your app passes review.

npm install base44-revenuecat
import revenuecat from 'base44-revenuecat'

await revenuecat.user(user.id)            // your Base44 user id = your RevenueCat customer id

const plans = await revenuecat.plans()    // live App Store / Google Play prices, trials, intro offers
await revenuecat.buy('monthly')           // native purchase sheet, trial applies automatically

await revenuecat.paywall()                // or: the RevenueCat paywall you designed in its dashboard

if (await revenuecat.has('premium')) unlockPremium()   // client-side entitlement gate

premium is a placeholder for your own entitlement id, not a built-in. RevenueCat ships no default entitlements, so nothing called premium exists until you create it yourself in the RevenueCat dashboard (Product catalog → Entitlements) and attach your App Store and Play Store products to it. Use your id everywhere this README writes premium. Until that entitlement exists and has products attached, has('premium') stays false for every user forever, including immediately after a real purchase that Apple or Google actually charged for. New to this? Read the entitlements guide first: what they are, how to create one, how to attach subscriptions and one-time purchases, and why credit packs work differently.

Every client call returns a promise and never throws. In the Base44 browser preview each method resolves a safe empty result, so you can build and preview your paywall logic on the web and it simply comes alive inside the installed app. (The server helpers are the one deliberate exception: they throw on failure so your backend fails closed - see Error handling.)

Requirements: the client runs anywhere Base44 runs (it's dependency-free UMD). The /server entry needs a runtime with global fetch: Base44 backend functions (Deno) work as-is, Node needs Node 18+.

Use 1.6.1 or newer - earlier versions have money-affecting bugs. On 1.6.0 and below, plans() mixed products from every offering, so a promotional price could be rendered while the full price was charged, and a short plan id could be repointed between render and purchase. On 1.5.x and below, a subscriber could read as not entitled on paths where an empty answer outranked a truer one. Both classes are silent: they do not throw, and they do not reproduce in your own account. npm install base44-revenuecat@latest.


Why this exists

Base44 builds your web app. Despia turns that Base44 app into a real native iOS and Android app you ship to the App Store and Google Play, with push notifications, native UI, and (via this package) real in-app purchases and auto-renewing subscriptions. Apple and Google require digital goods to go through StoreKit / Play Billing, so a Stripe checkout in a WebView will get your app rejected; this is the compliant path, and RevenueCat keeps both stores in sync for you.

You do not need webhooks, a subscriptions table, or native code. The two questions that matter are answered directly:

  • What should the app show this user?revenuecat.has('premium') on the device.
  • Should the server run this paid action?entitled(user.id, 'premium') in a Base44 backend function.

Setup (one time, ~15 minutes, no code)

Part 1: RevenueCat

  1. Create a free account at app.revenuecat.com (free until well past your first revenue).
  2. Project settings → Apps → + New → App Store: enter your iOS bundle id, upload an App Store Connect API key (App Manager role) and an In-App Purchase key. Shipping Android only? Skip this step - see Which key goes where.
  3. Project settings → Apps → + New → Play Store: enter the same package name and upload your Google Play service-account JSON. Shipping iOS only? Skip this step.
  4. Product catalog → Entitlements → + New: create one entitlement per thing you unlock, e.g. premium. Attach your App Store and Play Store products to it (both stores → one entitlement id, so your app code never branches per platform). The id you type here is the literal string you pass to has() and entitled() later, so choose it deliberately and copy it exactly. premium is only this README's example, and an entitlement you never created can never turn true. Attaching subscriptions, tiers and lifetime unlocks is covered in the entitlements guide - as is why consumable credit packs are the one thing you do not attach to an entitlement.
  5. Product catalog → Offerings: group products into an offering (the default offering is what paywalls show), e.g. a monthly and an annual package.
  6. Optional but recommended: design your paywall in Paywalls. revenuecat.paywall() presents it natively, priced in each user's own currency, and you can restyle it from the dashboard without an app update.
  7. Project settings → API keys: copy the iOS public SDK key (appl_…), the Android public SDK key (goog_…), and note your project id (proj…, shown in Project settings / the dashboard URL). A platform's key only exists once you have added that platform's app in step 2 or 3 - if you see no goog_…, the Play Store app has not been added yet.

Part 2: Despia (the only step that touches your app)

Open Despia → Your App → Settings → Integrations → RevenueCat and paste:

| Field | Value | |---|---| | iOS key | your appl_… public SDK key (leave empty if you don't ship iOS) | | Android key | your goog_… public SDK key (leave empty if you don't ship Android) | | Global project ID | your proj… project id |

Then trigger a new build. The RevenueCat SDK is compiled into the binary, so integration changes always need a rebuild. Until then purchases stay dormant.

That's the entire native setup. Everything else is the JavaScript below, written inside your Base44 app.

Which key goes where

RevenueCat gives you several keys and it is not obvious which one belongs where. There are only two slots, and they follow opposite rules - this is the single most common setup mix-up:

| Slot | Which key | Does an Apple key work for Android? | |---|---|---| | In the app (Despia integration fields) | must match the platform - one field per platform | No, never | | On your server (the entitled() check) | any one key from the project | Yes |

In the app, the key starts the store connection. An Android build talks to Google Play, and Google Play only accepts a goog_… key. Give an Android build an appl_… key and RevenueCat never starts: no paywall, no products, no purchases. That is why Despia has two separate fields rather than one - a build takes its own platform's key and ignores the other.

On the server, the key is only a password to read your project. It talks to no store. Entitlements are stored per customer per project, not per platform, so any key opens the same door - and it works in both directions. An appl_… key correctly reports a Google Play subscriber as entitled, and a goog_… key correctly reports an App Store subscriber as entitled. You need one key server-side, not one per platform, and no code in this package branches on platform.

Shipping only one platform? Everything still applies, minus the half you don't use. Add only that platform's app in RevenueCat, fill only that field in Despia, leave the other empty, and use that same key on the server:

| | iOS-only app | Android-only app | |---|---|---| | RevenueCat app to add | App Store | Play Store | | Despia field to fill | iOS key | Android key | | Key you'll have | appl_… | goog_… | | Server key to use | that same appl_… | that same goog_… |

Reading a key at a glance - the prefix tells you exactly what it is:

| Prefix | What it is | Safe in client code? | |---|---|---| | appl_… | iOS / App Store public SDK key | Yes - it ships inside your app binary | | goog_… | Android / Play Store public SDK key | Yes - same | | sk_… | Secret server-side key, project-wide | No - server only, keep it in Base44 secrets |

The two public keys are safe to paste into a dashboard or commit in a backend function. An sk_… key is not: it can read and modify your whole project, so it belongs in secrets and never in client code.

One genuine platform difference exists in the API itself: revenuecat.redeem() (Apple offer codes) returns { supported: false, code: 'unsupported' } on Android, because Google Play has no in-app redemption sheet. Everything else - purchase(), has(), paywall(), offerings(), restore(), customerCenter() - behaves identically on both platforms.


API

Small names, promises everywhere, identical behavior on iOS and Android.

revenuecat.user(id): connect Base44 users to RevenueCat customers

// Use your Base44 user's stable database id as the RevenueCat customer id.
// The SAME id is what your backend verifies later. One id everywhere.
const me = await base44.auth.me()
await revenuecat.user(me.id)

Call it once when your app knows who the user is (after Base44 auth), before showing a paywall. Use the stable id, not an email, name, or phone number. Before user(), purchases run under RevenueCat's own anonymous user (exactly how RevenueCat recommends it) and the anonymous history merges on first identify. (revenuecat.login(id) is an alias.)

Who is logged in right now? Call user() with no arguments. On current builds it asks the native RevenueCat SDK directly, so it works even before your code binds anyone:

const who = await revenuecat.user()
// { id: '$RCAnonymousID:abc...',  user: null,  anonymous: true,  registered: false }
// { id: 'base44_user_42',         user: 'base44_user_42', anonymous: false, registered: true }

if (!who.registered) await revenuecat.user(me.id)   // bind the account once

id is always the real RevenueCat app user id (anonymous ids included), user is the account id you bound (null when anonymous), and registered answers "is this RevenueCat user logged in?" in one boolean. A login the native SDK remembered from a previous session is picked up automatically.

Switching accounts is just another user(newId), with no logout in between; RevenueCat supports identifying straight from one user to the next:

await revenuecat.user(otherAccount.id)   // switch. Entitlements now reflect the new user

Logging out of your app:

await revenuecat.logout()
await base44.auth.logout()

On current builds logout() also asks the native layer to rotate to a fresh anonymous RevenueCat user where supported. Apps with accounts should gate premium on both signals, which is correct on every build ever shipped:

const premium = user && await revenuecat.has('premium')

revenuecat.whoami(): which RevenueCat customer is this device?

const me = await revenuecat.whoami()
// { id: '$RCAnonymousID:ab12…', user: null, anonymous: true, registered: false, source: 'native' }

Answers the question user(id) never tells you: who does RevenueCat currently think this device is? It reads the native SDK's own opinion, not this package's local state - which is the distinction that matters, because the RevenueCat SDK persists its identity across app restarts. A device can already be signed in as someone before your JavaScript has said a word.

| Field | Meaning | |---|---| | id | The customer id RevenueCat is using - either yours, or one it minted ($RCAnonymousID:…) | | user | The id you supplied via user(id), or null if none | | anonymous | true when purchases attach to the device, not to an account | | registered | true when an id you supplied is in force | | source | 'native' the SDK answered · 'local' this build has no identity read, so it is this package's own state · 'web' not inside an app |

source exists so "we could not ask" is never mistaken for "nobody is signed in". On a build without the native identity read, an answer of anonymous: true means we don't know, and treating it as this device has no account would be wrong.

Identity migration: anonymous → account, and account → account

The edge case worth understanding: a user buys before signing in. RevenueCat attaches that purchase to an anonymous customer tied to the device. When they later sign in, that purchase has to follow them.

const before = await revenuecat.whoami()
if (before.anonymous) {
  // Migrates: the native login merges the anonymous purchase history into
  // this account, so the subscription they already paid for follows them.
  await revenuecat.user(base44User.id)
}
const after = await revenuecat.whoami()   // confirm it landed, don't assume

| Situation | What happens | What you do | |---|---|---| | Anonymous device buys, then signs in | user(id) calls the native login and merges the anonymous history into the account | Nothing extra - but whoami() afterwards to confirm | | Account switch on a shared device (user_123user_234) | The cached catalog is dropped, so targeted offerings can never price one account off another's catalog | Re-read plans() after switching | | Signed-in user signs out | logout() rotates the SDK to a fresh anonymous customer, so the next person on the device does not inherit access | Gate on your own auth state too | | Same person, two devices | Both resolve to the same customer once user(id) names them | Always call user(id) at sign-in, on every device |

The one that bites: a logged-out user on a shared device can still read as entitled, because the SDK remembers the last identity across restarts and user() with no arguments adopts it. Gate on your own auth state as well as the entitlement:

const premium = base44User && await revenuecat.has('premium')

Support-ticket shortcut: when a customer says "I paid and it's locked", whoami() is the first thing to ask for. registered: false means their purchase is attached to a device rather than to their account, and the fix is a user(id) call at sign-in that your app is missing.

Both runtimes answer natively: Despia V4 via the whoami action, Despia V3 via revenuecat://whoami on builds with bridge ≥ 2. Older builds return source: 'local' rather than guessing.

revenuecat.plans(): build your subscription paywall screen

const plans = await revenuecat.plans()          // the current RevenueCat offering
// or: await revenuecat.plans('summer_sale')    // a specific offering

plans.forEach(plan => renderPlan({
  name:  plan.title,
  price: plan.price.text,                       // "AED 39.99", localized by the store
  trial: plan.trial                             // { days: 7, eligible } or null
}))

await revenuecat.buy(plans[0].id)               // 'monthly' / 'annual', plan ids work in buy()

Each plan is the same JSON on iOS and Android:

{
  id: 'monthly',                 // stable short id, feeds buy()
  rcId: '$rc_monthly',           // RevenueCat package
  product: 'premium_monthly',    // underlying store product id
  type: 'subscription',
  kind: 'monthly',               // weekly | monthly | annual | lifetime | custom
  title: 'Premium Monthly',
  desc: 'Full premium access',
  price:  { value: 39.99, text: 'AED 39.99', currency: 'AED' },
  period: { iso: 'P1M', value: 1, unit: 'month' },
  trial:  { days: 7, eligible: null },   // free trial, when configured (eligible: null = the store decides at purchase)
  intro:  null,                          // or { type: 'payg'|'upfront', eligible, price, period, cycles }
  offers: []                             // reserved: always empty today, see below
}

price.text is always the value to display. Never construct a currency string yourself, and never hardcode a price. On legacy classic-runtime builds an intro offer's numeric price.value can be null (the channel only reports the display string) - render price.text and never treat null as 0.

Two fields are reserved for native work that has not shipped yet, so don't branch on them:

  • plan.offers is always []. No current build emits a per-product offer list, so treat it as empty rather than "no offers configured".
  • trial.eligible and intro.eligible are always null, meaning "the store decides at purchase". That is not a gap you need to fill: Apple and Google enforce eligibility themselves at the moment of purchase, so a customer can never be granted a second free trial even if you show them one.

revenuecat.products(): App Store / Google Play products with live pricing

const products = await revenuecat.products()          // every product across your offerings
const monthly  = await revenuecat.products('default') // or just one offering

/* Each product is the SAME JSON on iOS and Android:
{
  id: "premium:monthly",        // pass straight to revenuecat.buy()
  sku: "premium",               // raw store product id (Android: without the base-plan suffix)
  plan: "monthly",              // Android base plan id; null on iOS and for one-time products
  type: "subscription",         // or "product" for one-time purchases
  title: "Premium Monthly",
  desc: "Unlimited everything",
  price: 9.99,                  // decimal, user's local currency
  priceString: "$9.99",         // localized by the store, render this
  currency: "USD",
  period: "P1M",                // ISO-8601: monthly
  periodUnit: "month",
  periodCount: 1,
  intro: {                      // free trial / intro offer, or null
    price: 0, priceString: "$0.00",
    period: "P1W", periodUnit: "week", periodCount: 1,
    cycles: 1, type: "trial"
  },
  offering: "default",
  package: "$rc_monthly",
  packageType: "monthly"
} */

This is how you build a custom paywall in Base44 with real store prices. Map over products, render priceString, and call buy(id) on tap. Never hardcode a price: the store localizes it per country.

About product ids: the two stores write subscription ids differently - iOS as one App Store product id, Android as subscriptionId:basePlanId. So the same "premium monthly" can appear as premium_monthly on an iOS device and premium:monthly on Android. Never compare against a hardcoded product id in app logic; gate on entitlement ids (has('premium')), which are identical on both platforms.

revenuecat.offers(offering?): the full catalog envelope

revenuecat.offers() returns the raw structure behind plans()/products() - { ok, current, offerings: [{ id, current, packages }], products, error, code } - when you need offering and package placement rather than a flat list.

revenuecat.buy(id): direct purchase

const result = await revenuecat.buy('monthly')   // plan id, product id, or plan object

if (result.ok) {
  // result.entitlements → ["premium"], active entitlements after purchase
  // result.transaction  → the store transaction id
  celebrate()
} else if (result.cancelled) {
  // user closed the sheet, no error to show
} else {
  show(result.error)
}

Resolves when the store sheet settles: success, user-cancel, or failure. Never rejects.

Purchases also work before anyone logs in: with no user(id) bound, the purchase attaches to RevenueCat's anonymous user for the device (the store still owns the receipt), and calling user(id) later merges that history onto the account. That is RevenueCat's recommended flow for apps where the paywall can appear before signup.

Free trials, introductory offers & discounts

Configure trials and intro pricing where they belong, App Store Connect / Google Play Console + RevenueCat, and they need zero code:

await revenuecat.buy('monthly')   // the store applies the trial automatically when eligible

Apple applies introductory offers to eligible customers by itself; on Android the bridge purchases RevenueCat's default option, which prefers the longest eligible free trial, then the cheapest intro phase, then base price. Eligibility is enforced by the store at purchase time, so you can't accidentally grant a second trial.

Show it before purchase from plans():

const [monthly] = await revenuecat.plans()

if (monthly.trial) {
  label.textContent = `${monthly.trial.days} days free, then ${monthly.price.text}/month`
} else if (monthly.intro) {
  label.textContent = `${monthly.intro.price.text} for your first ${monthly.intro.period.value} ${monthly.intro.period.unit}s`
}

All three Apple intro types are normalized (trial, and intro with type: 'payg' | 'upfront'), and Google base-plan offer phases map onto the same shape, so your UI code never branches per platform.

Targeted discounts (win-back offers, retention pricing): target with an offering, not a buy option.

// Build a "winback" offering in RevenueCat, then present it to the right people
const info = await revenuecat.info()
if (info.entitlements.premium?.unsubscribed) {
  await revenuecat.paywall('winback')        // or: await revenuecat.plans('winback')
}

info().entitlements.<id>.unsubscribed marks exactly the window worth targeting: cancelled, but still inside the paid period. Offering-based targeting works on both platforms today.

buy(id, { offer }) is not implemented natively yet. The package forwards the offer id, but no current build reads it: the native purchase action accepts only the product and the user id, so the store applies its default offer logic and your targeted price is silently not used. The option is accepted for forward compatibility, not because it works - don't build a discount flow on it. Use an offering, as above.

A misspelled offering id is refused, not silently widened. paywall('winbak') resolves { ok: false, code: 'offeringNotFoundError' } without presenting anything, rather than showing your default offering's full price where you meant a discount. The native layer itself does fall back, so the package checks the offering against the catalog before presenting - from an already-rendered catalog when there is one, so the common path costs no extra request. plans(id) and offers(id) answer the same offeringNotFoundError, so all three refuse the same inputs. Two consequences worth knowing: a build whose catalog read has degraded to a flat product list cannot name offerings at all, so a named offering is refused there (the bare paywall() is unaffected), and if the catalog read itself throws, the paywall is presented anyway - a flaky read must not cost every sale.

Tag manual-only Google offers rc-ignore-offer in RevenueCat so automatic selection never turns your win-back price into the acquisition price. This matters more while explicit-offer purchase is unimplemented: the tag is the only thing keeping a retention price out of the default selection. And if you use revenuecat.paywall(), RevenueCat Paywalls render configured trials, intro offers, and promotional offers for you, with no offer code at all.

revenuecat.redeem(): Apple offer codes

Apple's redeemable subscription codes for marketing campaigns:

const r = await revenuecat.redeem()   // opens Apple's redemption sheet on iOS
// r.supported === false on Android (Play codes are redeemed in the Play Store app)
// and on builds that predate the redeem bridge - always render your redeem
// button conditionally on r.supported

revenuecat.paywall(offering?): RevenueCat native paywall

const result = await revenuecat.paywall()            // the offering's paywall from your RC dashboard
// result.ok         → purchased (or restored: result.restored === true)
// result.cancelled  → closed without buying
if (result.ok) unlockPremium()

One resolved promise per presentation. The paywall renders natively, shows each user their own currency, and is A/B-testable and restylable from the RevenueCat dashboard without an app update. That is the strongest option for conversion.

Branch on result.ok / result.cancelled, not on which product was bought: on Android a paywall purchase resolves product and transaction as null (the native paywall result exposes only the customer info). result.entitlements is populated on both platforms, so gate on that, or call has() / status() afterwards.

revenuecat.has(entitlement): the client gate

if (await revenuecat.has('premium')) unlockPremium()

The one-liner for showing and hiding premium UI. Checks RevenueCat's entitlement state plus the device's own store history, so it also works offline and before login. Use it on app load and after every purchase, and keep anything truly valuable behind the server check below.

revenuecat.status() / revenuecat.restore(): full entitlement snapshot

const s = await revenuecat.status()
// s.active        → ["premium"]            active entitlement ids
// s.all           → ["premium", "plus"]    every entitlement ever seen for this user
// s.subscriptions → ["premium:monthly"]    active subscription products (store-native id form)
// s.purchases     → full store history rows (transaction ids, expiry, renewal state)
// s.management    → deep link to the native manage-subscription screen (or null)
// s.details       → raw per-entitlement lifecycle state (current builds; info() is the friendly view)

revenuecat.restore() re-reads the device's purchase history. Wire it to a "Restore purchases" button (App Store review expects one; the RevenueCat paywall and Customer Center also include their own).

revenuecat.info(): per-entitlement lifecycle for account screens

The same snapshot as status(), plus a normalized per-entitlement detail map - the right read for "Manage subscription" screens and win-back logic:

const info = await revenuecat.info()
const p = info.entitlements.premium
// p.active        → access right now
// p.period        → 'trial' | 'intro' | 'promo' | 'normal' | 'prepaid' | null (older builds)
// p.bought        → purchase date, p.expires → current period end
// p.renews        → false the moment the user turns auto-renew off, even while access continues
// p.unsubscribed  → when the user cancelled while still inside the paid period
//                   (the window where a win-back offer is worth showing)
// p.billingIssue  → set while the store retries a failed payment (grace period)
// p.store         → 'app_store' | 'play_store' | 'stripe' | 'promotional' | ...
// p.ownership     → 'purchased' | 'family_shared'
// p.sandbox       → true for sandbox/TestFlight purchases
// info.manage     → deep link to the native manage-subscription screen

On current builds these come straight from RevenueCat's own lifecycle state (they know about renewals, cancellations inside the paid period, and billing retries). Older builds fall back to inference from store history, where the extra fields are absent, so feature-detect: if (p.unsubscribed) showWinBack().

revenuecat.center(): Customer Center

const r = await revenuecat.center()   // native manage / restore / cancel / refund UI
// r.ok → the user closed it; r.code === 'unsupported' → this build has no Customer Center

revenuecat.ready(): environment snapshot on app start

const env = await revenuecat.ready()
// { native, os, runtime, user, project }, handy for gating your paywall route

Events

const stop = revenuecat.on('result', (r) => refresh())    // every purchase/paywall outcome
revenuecat.on('purchase', () => refresh())                // store confirmed a transaction / renewals
revenuecat.on('center', (e) => console.log(e.event))      // Customer Center activity
revenuecat.on('user', (u) => refresh())                   // identity reported (login/logout/whoami settled)
stop()                                                    // unsubscribe one listener
revenuecat.off('purchase')                                // or remove every listener for an event

on() returns an unsubscribe function; call it (or off(event, fn)) when a component unmounts so listeners don't accumulate across remounts.

Environment

revenuecat.native    // true inside your installed Despia app
revenuecat.os        // 'ios' | 'android' | 'web'
revenuecat.runtime   // 4 = Despia Framework, 3 = classic Despia, 0 = browser
revenuecat.id        // current RevenueCat customer id (null when anonymous)
revenuecat.project   // your RevenueCat project id (auto-filled from the Despia build)
revenuecat.debug = true   // log verbose bridge diagnostics to the console

Web fallback in one line: send browser users to a RevenueCat Web Purchase Link:

if (!revenuecat.native) location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(user.id)}`

Gate premium features (the complete client pattern)

import revenuecat from 'base44-revenuecat'

async function refreshAccess () {
  applyPremium(await revenuecat.has('premium'))
}

await revenuecat.user(user.id)      // as soon as Base44 auth resolves
refreshAccess()              // on load
revenuecat.on('result', refreshAccess)    // after purchases, restores, cancellations
revenuecat.on('purchase', refreshAccess)  // renewals & server-side changes

The UI follows the store, never a tier flag you set yourself.

Verify on the server (Base44 backend function, no webhooks, no secrets)

Client checks decide what to show. Anything a tampered client could steal (paid exports, credits, premium endpoints) gets verified when the request arrives, by asking RevenueCat directly. No webhook handlers, no subscription table to keep in sync, and for the standard check, no secret key either: RevenueCat's v1 subscriber endpoint accepts your public SDK key, so the only configuration is a value that is public by definition.

// functions/premium.js, runs server-side in Base44
import { createClientFromRequest } from 'npm:@base44/sdk'
import { entitled } from 'npm:base44-revenuecat/server'

// Your PUBLIC SDK key from Despia → Integrations → RevenueCat (safe to paste:
// it ships inside your app binary anyway). Configure it HERE, server-side;
// never read it from the request.
//
// EITHER platform's key works here, and you only need one. The server reads
// your project, not a store: an appl_ key verifies Google Play subscribers and
// a goog_ key verifies App Store subscribers. Use whichever you have.
// (In the app it's the opposite - the key must match the platform. See
// "Which key goes where" above.)
const RC_KEY = 'appl_XXXXXXXXXXXX'   // or 'goog_XXXXXXXXXXXX'

export default async function (req) {
  const base44 = createClientFromRequest(req)

  // auth.me() THROWS when the request carries no signed-in session - it does
  // not return null. Left uncaught it becomes a 500 reading "Authentication
  // required to view users", which looks like a purchase bug and is not one.
  let user
  try {
    user = await base44.auth.me()        // server-verified identity, never trust a client-sent id
  } catch (e) {
    return Response.json({ error: 'sign in required' }, { status: 401 })
  }

  let ok = false
  try {
    ok = await entitled(user.id, 'premium', { key: RC_KEY })
  } catch (e) {
    // RevenueCat unreachable / rate limited: fail CLOSED - deny the paid
    // action rather than giving it away. See "Error handling" below.
    return Response.json({ error: 'verification unavailable, retry shortly' }, { status: 503 })
  }
  if (!ok) return Response.json({ error: 'premium required' }, { status: 402 })

  return Response.json({ premium: true /* , ...do the paid work here */ })
}

Three different denials, three different codes. They all withhold the paid action, but they mean different things to your app and to your metrics - collapse them and every one of these looks like "the purchase is broken":

| Code | Means | What the app should do | |---|---|---| | 401 | Nobody is signed in | Send them to sign-in. There is no user to verify yet. | | 402 | Signed in, not subscribed | Show the paywall. This is the normal upsell path. | | 503 | Cannot verify right now | Ask them to retry. Never show a paywall - a paying subscriber must not be asked to buy again because RevenueCat was briefly unreachable. |

The 401 case is the one people hit first, because a store or pricing page is often reachable before sign-in. Server-side verification is per-user by design: it looks up entitlements for the authenticated user's id - the same id the app binds with revenuecat.user(id) - so an anonymous request has nothing to check. Either gate the page behind sign-in, or handle the 401 by prompting for it.

Call it from your app:

const { data } = await base44.functions.invoke('premium', {})

Because the frontend used revenuecat.user(user.id) and the function uses base44.auth.me().id, both sides always name the same RevenueCat customer. That is the number-one integration mistake, eliminated.

One side effect to know about: RevenueCat's v1 subscriber endpoint is create-on-read - checking an id RevenueCat has never seen creates that customer (the API answers 200 or 201). The check itself stays correct (a just-created customer has no entitlements, so gates deny), but every distinct user id you verify will appear in your RevenueCat customer list, including users who never opened the mobile app. That's cosmetic for most apps; if you'd rather avoid it, only call entitled() for users who have actually been through the app's paywall, or use the secret-key path below.

Optional upgrade (secret key): pass { secret: secrets.get('RC_SECRET'), project: secrets.get('RC_PROJECT') } (a sk_… key from RevenueCat → API keys, stored in Base44 secrets) to ride RevenueCat's v2 API with project scoping and documented rate limits (480 customer-info requests/min per key; v1 publishes no figure). The v2 customer read does not create customers. Entitlements are matched by their human lookup key (premium) on both paths, and if v2 rejects the key or project (401/403/404) the check falls back to v1 automatically and remembers the verdict.

Rate limits, and sizing a launch

Every gated request is one RevenueCat request unless you cache. That is the most likely way a working integration stops working under load, so here are the numbers rather than "higher limits":

| Path | Published limit | |---|---| | v2 (sk_… secret key) | 480 customer-info requests/minute per key - roughly 8/second | | v1 (public appl_…/goog_… key) | RevenueCat publishes no figure |

Eight requests a second is a real ceiling for a server that checks entitlement on every request: ~480 gated requests/minute is one active user hitting eight endpoints a second, or a few hundred users browsing normally. Past it you get 429, entitled() throws, and fail-closed handling turns that into a 503 for customers who have paid.

Two things keep you under it:

  • Cache positive answers. { cacheMs: 30000 } serves a grant from memory for 30 seconds. Only grants are cached, never denials - a customer who subscribes mid-session is never held behind the TTL, while a cancellation costs at most cacheMs of extra access. Off by default, because the right value depends on how much post-cancellation access you will tolerate.
  • Check once per request, not once per gate. Resolve entitlement at the top of a backend function and pass the boolean down, rather than calling entitled() in three places on one request.
// One read per user per 30s, instead of one per request.
const ok = await entitled(user.id, 'premium', { key: RC_KEY, cacheMs: 30000 })

On retrying a 429: entitled() does not retry - deliberately. It surfaces .status and .retryAfter and lets you decide, because a server-side gate is usually inside a request whose own deadline is shorter than the interval RevenueCat asks you to wait, and a hidden retry would spend that budget without telling you. Back off at the layer that knows the deadline, and prefer cacheMs to retrying at all.

Configuration details (all three functions accept the same options):

  • Resolution order: explicit { key | secret | project } → env RC_KEY / RC_SECRET / RC_PROJECT → env REVENUECAT_PUBLIC_KEY / REVENUECAT_SECRET_KEY / REVENUECAT_PROJECT_ID.
  • secret wins over key when both are set. Which API path runs is decided by the key's own prefix - only sk_… keys ever use v2; an sk_… key placed in RC_KEY still unlocks v2, and a public key in RC_SECRET still works on v1.
  • { timeout: ms } bounds each RevenueCat request (default 10 s); a hung connection aborts and throws instead of hanging your function.
  • { cacheMs: 30000 } caches a positive answer for that long (off by default). Grants only - never denials, so a customer who just subscribed is never held behind the TTL. See Rate limits.
  • { sandbox: true } (or env RC_SANDBOX=true) includes sandbox purchases - required while testing, see Testing. Off in production. A sandbox check always uses RevenueCat's v1 API even if you configured a secret key and project id: X-Is-Sandbox is a v1 header and v2 has no documented sandbox support, so routing a tester through v2 would deny every sandbox purchase. Production checks are unaffected.
  • A v2 "not entitled" is double-checked. On the secret-key path, RevenueCat's active_entitlements decides who is active - but its rules for grace periods and other still-granting states aren't documented, and it has been seen returning nothing for a customer v1 reports as entitled. So when v2 lists nothing for a customer it knows, the check confirms against v1 before denying. Granting wrongly costs a little revenue; denying a paying customer costs the customer. That's one extra request only on the about-to-deny path - a positive answer and an unknown customer both cost nothing extra. Pass { confirmDenials: false } to take v2 at its word if your traffic is mostly never-subscribed users.
  • Errors carry detail. The thrown Error has .status (the HTTP status), and on a 429 also .retryAfter in seconds when RevenueCat sends a Retry-After header - so you can back off for the interval the API actually asked for:
try {
  ok = await entitled(user.id, 'premium', { key: RC_KEY })
} catch (e) {
  if (e.status === 429) console.warn(`rate limited, retry in ${e.retryAfter ?? 60}s`)
  return Response.json({ error: 'verification unavailable' }, { status: 503 })
}

Server return payloads (copy these exactly)

The /server entry exports three functions and works in any Node 18+ or Deno backend. Here is precisely what each one resolves to, because guessing the shape is the single easiest way to ship a bug that returns a plausible wrong answer instead of an error:

// 1. entitled(user, entitlement, opts?) → boolean
await entitled('user_123', 'premium', { key: RC_KEY })
// true

// 2. entitlements(user, opts?) → AN ARRAY of objects (not a map, not an object)
await entitlements('user_123', { key: RC_KEY })
// [
//   { id: 'premium', expires: '2026-08-11T16:30:46.000Z' },   // ISO string
//   { id: 'lifetime', expires: null }                          // null = never expires
// ]

// 3. customer(user, opts?) → the raw RevenueCat v1 subscriber object, or null
await customer('user_123', { key: RC_KEY })
// { entitlements: {...}, subscriptions: {...}, first_seen: '...', ... }

entitlements() returns an ARRAY, so Object.keys() gives you ["0","1"]. This is the mistake worth naming, because it fails quietly: array indices are valid strings, so the response looks structurally fine and ships a list of positions where entitlement ids should be. If you ever see "0", "1" in an entitlement list, this is why - nothing is wrong with your RevenueCat setup.

const active = await entitlements(user.id, { key: RC_KEY })

Object.keys(active)                       // ✗ ['0', '1']        positions
active.map((e) => e.id)                   // ✓ ['premium', 'lifetime']
active.some((e) => e.id === 'premium')    // ✓ true
active.find((e) => e.id === 'premium')    // ✓ { id, expires }

Or skip the shape entirely. If all you need is a gate, entitled() answers a plain boolean and there is nothing to destructure:

if (!await entitled(user.id, 'premium', { key: RC_KEY })) return deny()

Reach for entitlements() only when you need the expiry dates or the full list.

customer() is the one shape this package does not normalize - it is RevenueCat's raw v1 subscriber object, passed through deliberately so you can read fields this package does not model. Its entitlements field is a keyed object (RevenueCat's own shape), which is exactly why the two are easy to confuse: entitlements() gives you an array of { id, expires }, while customer().entitlements gives you RevenueCat's raw map. Different functions, different shapes, on purpose.

Error handling

Client (base44-revenuecat): resolves, never rejects. Every call answers with an envelope carrying ok, a human-readable error, and a machine-readable code:

| code | Meaning | |---|---| | web | Not running inside a Despia app (browser / Base44 preview) | | no_module | The RevenueCat module is excluded from this build | | unsupported | This build doesn't carry the requested feature (e.g. Customer Center, redeem) | | timeout | The native layer didn't answer in time | | call_failed | The native call itself threw | | empty | The native layer answered with nothing | | missing_param | A required argument was missing or invalid | | user_cancelled | The user closed the store sheet without buying | | offeringNotFoundError | No offering matched the requested id |

Native layers may surface additional store-specific codes verbatim. Branch on result.cancelled first (not an error to show anyone), then show result.error for the rest.

Server (base44-revenuecat/server): throws, on purpose. entitled(), entitlements(), and customer() throw when no API key is configured, when RevenueCat answers non-2xx (including 429 rate limits - the thrown error has a .status), and on network failures or timeouts. Always wrap them in try/catch and fail closed: deny the paid action on error, as in the example above. Silent false would be indistinguishable from "not subscribed"; a thrown error tells you verification itself failed.

What about cancellations?

You learn about a lapse at the user's next check, which is their next request anyway, because both gates re-ask RevenueCat every time. That is exactly what webhooks would have told you, minus the retries, signatures, and event tables. If you later want real-time pushes (lapse emails, live session cuts), add a RevenueCat webhook as an extra layer, it's never the thing to debug before your first paying customer.


Works on every Despia runtime

The package detects the runtime at call time and speaks its native dialect. Same API, same JSON, either way:

| Runtime | Detection | Transport | |---|---|---| | Despia Framework (V4) | window.dsx module bus | dsx.module.revenuecat.* promises | | Despia classic (V3) | despia user agent | revenuecat:// schemes + window callbacks | | Browser / Base44 preview | neither | safe no-op resolutions |

Detection is automatic and does not depend on you: the Despia Framework is identified by its module bus, the classic runtime by its user agent, and anything else resolves safe empties so your Base44 preview keeps working.

Every native capability is probed at call time and falls back to the next best read the build actually has, so an older binary degrades instead of failing. plans() tries the unified catalog, then the offerings read, then the flat product list; has() tries the customer envelope, then the entitlements read, then the device's purchase history; paywall() accepts either action spelling. Newer capabilities (native session login and logout, the user() identity read, RevenueCat-anonymous purchases, explicit offers, offer-code redemption) are probed the same way, and on builds that predate the anonymous fallback the package quietly supplies a stable per-device id for purchases. You never version-match JavaScript against binaries, and rebuilding in Despia simply upgrades the path each call takes.

Prompt for AI builders

Building with Base44's AI (or any coding agent)? Paste this into your prompt:

Using the base44-revenuecat npm package:

1. Identify the signed-in Base44 user with: await revenuecat.user(user.id)
2. Load live subscription plans with: await revenuecat.plans()
3. Build the paywall screen from the returned plans. Always display plan.price.text
   (the store-localized price). Show plan.trial.days when a free trial exists.
4. Purchase with: await revenuecat.buy(plan.id). Check result.ok and result.cancelled;
   the call resolves (never throws).
5. Gate premium UI on: await revenuecat.has('premium'), replacing 'premium' with an
   entitlement id that actually exists in this project's RevenueCat dashboard
   (Product catalog, Entitlements). Ask which id to use rather than inventing one:
   an entitlement that was never created is false for every user, always.
6. Add a "Restore purchases" button calling: await revenuecat.restore()
7. On app logout call: await revenuecat.logout()
8. Do not hardcode prices or currencies.
9. Do not create a subscriptions table and do not add RevenueCat webhooks.
10. Do not install any other RevenueCat SDK (no Capacitor/cordova plugins). The
    native SDK ships inside the Despia build.

Testing

  • iOS: test on TestFlight with a Sandbox Apple ID (Settings → App Store → Sandbox Account). Sandbox renewals are accelerated (a month ≈ 5 minutes).
  • Android: add your Google account as a license tester in Play Console, install from an Internal Testing track.
  • Purchases in sandbox are free and RevenueCat's dashboard shows them within seconds. Watch the Customer view while you test (its Sandbox data toggle controls what you see).
  • The server check does NOT see sandbox purchases by default. RevenueCat's API answers with production purchases only, so a sandbox subscription makes the client say entitled and entitled() say not. Pass { sandbox: true } (or set RC_SANDBOX=true) while testing, and turn it off for production:
const testing = Deno.env.get('RC_SANDBOX') === 'true'
const ok = await entitled(user.id, 'premium', { key: RC_KEY, sandbox: testing })

Versioning policy

base44-revenuecat follows semantic versioning. The history moves fast - 1.4.2 to 1.6.1 inside a week - so here is what a bump actually means for you, because "it's semver" does not tell you whether an upgrade is safe:

| Bump | What it can contain | Safe to take without reading? | |---|---|---| | Patch (1.6.1 → 1.6.2) | Bug fixes. A gate that wrongly denied may start granting, since that direction is a fix, not a break. No API removals, no renames, no new required config. | Yes | | Minor (1.6.x → 1.7.0) | New methods, new optional options, new fields on returned objects. Everything that worked keeps working and keeps its shape. | Yes | | Major (1.x → 2.0.0) | Removals, renames, changed return shapes, a raised minimum Node version. | Read the changelog first |

Three commitments that hold across every version inside a major:

  • Client calls never throw. A method may gain a new code value, never a rejection. The /server helpers are the deliberate exception and always throw, so backend gates fail closed.
  • A newer package on an older Despia build degrades, it does not break. Native capabilities are probed at runtime, so you never have to match a package version against a compiled binary, and upgrading this package never requires a rebuild.
  • Fields are added, not repurposed. An existing field keeps its meaning and its type. New information arrives as a new field, so reading one defensively is enough.

The one thing a patch release deliberately does change is behaviour that was wrong about money - a paying customer who read as not entitled, or a price that was rendered from one product and charged from another. Those are shipped as patches on purpose: leaving them behind a major version would mean the safest upgrade path is the one that keeps the bug.

Troubleshooting

  • products() returns [] in the installed app → the build predates the RevenueCat integration or the keys were added after the last build: check Despia → Integrations → RevenueCat, then rebuild. Also confirm your products are attached to an offering in RevenueCat.
  • has('premium') is false right after buying → in order of likelihood: (a) you copied premium out of these docs and no entitlement with that id exists in your RevenueCat dashboard, so it can never be true. Use your own id. (b) The entitlement exists but the purchased product is not attached to it (Product catalog → Entitlements). (c) The id differs by case or whitespace: it is matched literally, so Premium is not premium. (d) You bought a consumable or credit pack, which grants no entitlement by design. Check result.ok for those, not has(). status() shows the ids the device actually sees, which is the fastest way to tell these apart.
  • A 500 with "Authentication required to view users", or AxiosError: Request failed with status code 500 from your own function → this is not a RevenueCat or purchase failure. base44.auth.me() throws when the request carries no signed-in session rather than returning null, and an uncaught throw becomes a generic 500. Wrap it and answer 401 instead, as in the server example. It shows up first on store, pricing or paywall pages, which are often reachable before sign-in - server verification is per-user, so an anonymous request has no id to check. Either gate the page behind sign-in or handle the 401 by prompting for it. Do not "fix" it by falling back to a client-side has() check: that is the gate a tampered client can lie to, which is the reason the server check exists.
  • Server check says false, client says true → two causes, in order of likelihood. (a) You are testing in sandbox. RevenueCat's API returns production purchases only unless you pass { sandbox: true } (or set RC_SANDBOX=true), so a TestFlight or license-tester purchase is invisible to entitled() while the device can see it. (b) The ids differ: log revenuecat.id in the app and user.id in the function, they must be identical strings.
  • Purchases work on one platform but the other is completely dead (no paywall, no products, products() empty) → that platform's key is missing or holds the wrong platform's key in Despia → Integrations → RevenueCat. In the app a key must match its platform: an Android build cannot start with an appl_… key, and an iOS build cannot start with a goog_… key. Fill the matching field and rebuild. (The server check is the opposite - one key of either platform verifies everyone. See Which key goes where.)
  • Nothing happens in the browser → correct: purchases only exist inside the installed iOS/Android app. Preview logic with revenuecat.native.
  • A buy() call seems stuck right after a paywall was shown → purchase, paywall, and redeem outcomes share one native result channel and are processed in order. If a paywall was presented but the native layer never reported its outcome (e.g. the app was killed mid-sheet), queued purchase calls wait behind it until the paywall wait times out. A fresh app start clears the queue.
  • has() feels slow / you call it on every render → each has()/status() call re-asks the native layer (a customer read, plus store history on classic builds). Check once per screen or on the result/purchase events, keep the boolean in your app state, and re-check after purchases - as in the complete client pattern.
  • A logged-out user on a shared device still shows premium → the native RevenueCat SDK remembers the last identified user across app restarts, and user() (no arguments) adopts it. Gate premium UI on your own auth state too: const premium = user && await revenuecat.has('premium'), and call revenuecat.logout() when your app logs out.

FAQ

Can a Base44 app have in-app purchases?

Yes. Publish your Base44 app as a native iOS and Android app with Despia, enable the RevenueCat integration, and this package gives you App Store and Google Play subscriptions with a promise-based JavaScript API, with no Swift, no Kotlin, no webhooks.

How do I put my Base44 app on the App Store and Google Play?

Despia wraps your Base44 app in a real native binary and walks you through store submission. See despia.com and the setup guides.

Can I use Stripe for subscriptions inside a Base44 mobile app?

Not for digital content and features used inside the app. Apple and Google require their own billing there, and a Stripe checkout in a web view is one of the most common causes of rejection. This package uses StoreKit and Play Billing, which is the compliant path. Stripe remains the right tool for physical goods, real-world services, and your web checkout.

Do I need my own backend for subscriptions?

No. Client gating works with zero backend. For protected server actions, a single Base44 backend function with entitled() is enough. RevenueCat is the source of truth, you never mirror it into your own tables.

What is the difference between a product and an entitlement?

A product is what the store sells (premium_monthly, premium_annual). An entitlement is what it unlocks (premium). Attach both your iOS and Android products to one entitlement, then gate your app on has('premium') and your code never branches per platform or per plan.

You name the entitlement yourself when you create it in RevenueCat, and both names above are just examples. There is no standard or built-in entitlement id, so premium carries no special meaning to RevenueCat, to Despia, or to this package. Whatever you type in the dashboard is the exact string your code must pass.

The entitlements guide covers this end to end: creating one, attaching subscriptions and lifetime unlocks, why consumable credits are not entitlements, stacked tiers, and reading the lifecycle correctly.

How do I test purchases before launch?

On iOS use a Sandbox Apple ID through TestFlight, where a month renews in about five minutes. On Android add yourself as a license tester in Play Console and install from an Internal Testing track. Sandbox purchases are free and appear in the RevenueCat dashboard within seconds.

Why does products() come back empty?

Almost always one of three things: the app was built before the RevenueCat keys were added in Despia (rebuild), the products are not attached to an offering in RevenueCat, or the store products are not yet approved. See Troubleshooting.

Does this work with other AI app builders?

The package targets Base44 conventions, but the native layer underneath is Despia, which turns any web app into an iOS and Android binary. If you build with a different tool, the same RevenueCat integration is available through despia-native.

What does RevenueCat cost?

Free until well past your first revenue (see revenuecat.com/pricing); no card needed to start.


Links

License

Apache-2.0 © Despia