Skip to main content

Command Palette

Search for a command to run...

WebView In-App Purchases Without Webhooks (RevenueCat)

WebView in-app purchases without webhooks: restore entitlements on the frontend and verify server-side with a RevenueCat check when it matters.

Updated
6 min readView as Markdown
WebView In-App Purchases Without Webhooks (RevenueCat)

A WebView app is a web app running inside a native shell, so a purchase has to cross from your JavaScript to the platform store. Despia is that native shell, and it gives your web layer RevenueCat's native billing. That sounds like it should demand a backend and a webhook pipeline. It does not. Whatever framework your web layer uses, you can take subscriptions with a restore on the frontend and a single check on the server, and never wire an event handler at all. RevenueCat stays the source of truth on both sides of the bridge.

Why a WebView app does not need the webhook

The purpose of a webhook is to keep a copy of subscription state in your own database. Maintaining that copy is the actual work: failed deliveries, replays, ordering, signature checks, and drift the moment one event is lost. RevenueCat already knows who is subscribed, so you can ask it instead of shadowing it. Only two things in your app depend on the answer. What the WebView renders, which the device settles on its own, and whether a protected server call proceeds, which the server settles. One is a restore, one is an API call, and there is no event to catch between them.

Build the paywall in RevenueCat and enable it in Despia

Whatever framework runs inside the WebView, the products and paywall are set up once in RevenueCat, and Despia switches the integration on at build time.

In RevenueCat:

  1. Create a project at app.revenuecat.com. It stays free up to a monthly revenue threshold.

  2. Add an App Store app with your iOS bundle id, an App Store Connect API key that has App Manager access, and an in-app purchase key, so RevenueCat can validate receipts.

  3. Add a Play Store app with the same package name and your Google Play service account JSON.

  4. Create entitlements for what users unlock, import your products, and attach each product to an entitlement.

  5. Group the products into an offering (a default offering with monthly and annual packages is typical) and design the paywall against it in RevenueCat's paywall editor.

  6. Copy the iOS and Android public SDK keys from Project settings.

In the Despia Editor, open App, Settings, Integrations, RevenueCat, paste both keys, and rebuild. The SDK is compiled into the binary and cannot arrive over the air, so without a rebuild the integration stays inactive, purchases fail silently, and entitlement checks come back empty.

Once built, install the SDK with npm install despia-native and open the paywall from your web layer:

import despia from 'despia-native'

const isDespia = navigator.userAgent.toLowerCase().includes('despia')

if (isDespia) {
  despia(`revenuecat://launchPaywall?external_id=${userId}&offering=default`)
}

Use RevenueCat's native paywall rather than building your own in the web layer. It renders localized pricing and currency out of the box, comes as conversion-focused templates, and updates from the RevenueCat dashboard without a rebuild. The reference covers alternative setups.

Frontend: restore across the bridge

The native shell exposes the store to your web layer, so the restore is a single call regardless of framework. Gate the UI on the entitlement it returns, not on any local flag.

import despia from 'despia-native'

const isDespia = navigator.userAgent.toLowerCase().includes('despia')

// ask the native layer which entitlements are active, then update the UI
async function refreshEntitlements() {
  if (!isDespia) return
  const { restoredData } = await despia('getpurchasehistory://', ['restoredData'])
  const active = (restoredData ?? []).filter(p => p.isActive)
  setPremiumUI(active.some(p => p.entitlementId === 'premium'))
}

refreshEntitlements()                              // on load
window.onRevenueCatPurchase = refreshEntitlements  // and after a purchase

Create one entitlement in RevenueCat and attach both platform products, so iOS and Android come back with the same entitlementId and your code stays framework and platform agnostic. Run the restore on load, on navigation, and before revealing a gated feature. The calls are instant and work offline, since the stores cache entitlement state on the device. Treat onRevenueCatPurchase as a prompt to re-check rather than an unlock, so access always follows the store.

This restore reflects what the client claims. That is correct for showing and hiding parts of the interface, and it is not sufficient for protecting anything a manipulated client could walk away with.

Backend: verify when the call is made

For any request that costs money or serves protected content, check server-side at request time. The client sends the app user id it uses with RevenueCat, and your server asks RevenueCat whether that user is entitled before acting.

// server-side only, the secret key never ships inside the WebView
async function hasActiveEntitlement(appUserId, entitlementId) {
  const id = encodeURIComponent(appUserId) // anon ids carry a $RCAnonymousID: prefix
  const res = await fetch(
    `https://api.revenuecat.com/v2/projects/${PROJECT_ID}/customers/${id}/active_entitlements`,
    { headers: { Authorization: `Bearer ${RC_SECRET_KEY}` } }
  )
  if (res.status === 404) return false        // customer RevenueCat has never seen
  if (!res.ok) throw new Error(`RevenueCat ${res.status}`)
  const { items = [] } = await res.json()
  return items.some(e => e.entitlement_id === entitlementId)
}

// gate the protected route
if (!(await hasActiveEntitlement(userId, 'premium'))) {
  return new Response('subscription required', { status: 402 })
}
// entitled, run the protected work

The userId in this check must match the external_id you used to launch the paywall. If they do not line up, RevenueCat resolves a different customer and the check correctly returns no entitlement.

Keep the secret key on the server, url-encode the app user id, and expect a list of entitlements each with an id and an expiry (null for lifetime), not any product detail. Most users fit one page of results. Cache the answer per user for a short window if you check on every request. In sandbox the endpoint sometimes reads empty even when the entitlement is active, so confirm against the older subscriber lookup while you are testing. The API also differs across endpoints and versions, so verify the active-entitlements route against the current RevenueCat docs before you ship.

What you give up

Without webhooks you lose real-time reaction. A cancellation will not cut a live session or fire a lapse email on the same second; you find out at the next check, which is the user's next request in practice. If a feature genuinely needs instant state, add a webhook then, as a layer on top of this setup rather than a prerequisite for it.

Turn your WebView app into a store app

Despia runs your web app as a real iOS and Android binary with native purchases and restores through one JavaScript call, from the single codebase you already ship.

Read the full RevenueCat setup guide at setup.despia.com